> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Inbox API

> Push approval requests, notices, and connection-fix cards onto a user's desk from any execute context

## Overview

The Inbox API lets your agent **ask for something and wait** — instead of hoping the user is in the conversation right now. `User.Inbox.push()` puts a card on the user's desk: an approval to click, a notice to read, or a broken integration to reconnect. You get a receipt back immediately; the user deals with the card whenever they next open their inbox.

```typescript theme={null}
import { User } from 'lua-cli';

const receipt = await User.Inbox.push({
  title: 'Approve the Q3 renewal quote',
  body: 'Northstar Ltd renewal is ready to send at $48,000.',
  actions: ['approve'],
  key: 'northstar-q3-renewal',
});

// { outcome: 'deposited', kind: 'input_request', key: 'northstar-q3-renewal' }
```

<Note>
  This is the counterpart to [Channels](/api/channels). `Channels.*` sends a **message** into a conversation and expects the user to read it there. `Inbox.push` files a **task** that survives being ignored — it stays on the desk until it is acted on or expires.
</Note>

<CardGroup cols={3}>
  <Card title="Approval" icon="circle-check">
    Options the user resolves in one click
  </Card>

  <Card title="Notice" icon="bell">
    A finished-work or heads-up card
  </Card>

  <Card title="Connection fix" icon="plug">
    An integration the agent needs reconnected
  </Card>
</CardGroup>

## Where you can call it

`User.Inbox.push()` takes **no recipient** — the card always goes to the user of the current execution context. That makes it available exactly where your code already has an ambient user.

| Context                                  | Available? | Who gets the card                    |
| ---------------------------------------- | ---------- | ------------------------------------ |
| Tool `execute`                           | ✅          | The user in the current conversation |
| [Dynamic job](/api/jobs) (`Jobs.create`) | ✅          | The user who triggered the job       |
| Local run against your agent             | ✅          | **You** — the signed-in developer    |
| [Pre-defined `LuaJob`](/api/luajob)      | ❌          | Context-less — no ambient user       |
| [Webhook](/api/luawebhook)               | ❌          | Context-less — no ambient user       |

<Warning>
  The recipient is derived from the execution context or your own credentials — never from the payload, and it cannot be overridden. This is the same context split that makes `User.get()` work in a tool but require an explicit `User.get(userId)` in a webhook: surfaces with no ambient user have nothing for `Inbox.push` to target, and a push from one fails with `no_user_context`.

  To reach a specific user from a webhook or a pre-defined job, message them directly with [`user.send()`](/api/user) or [Channels](/api/channels) instead.
</Warning>

## push(input)

```typescript theme={null}
const receipt = await User.Inbox.push(input);
```

### Parameters

<ParamField path="title" type="string" required>
  The card's first line. Trimmed and capped at 140 characters.
</ParamField>

<ParamField path="body" type="string" required>
  The human explanation — the card's context line, and the prose in the detail pane. Capped at 1000 characters.
</ParamField>

<ParamField path="detail" type="string">
  Longer text shown only when the user opens the card. Capped at 4000 characters.
</ParamField>

<ParamField path="deeplink" type="string">
  A source link for the card — an issue URL, a document permalink. Must be `http://` or `https://`. Rendered as a link the user can follow; never opened automatically.
</ParamField>

<ParamField path="priority" type="'urgent' | 'high' | 'normal' | 'low'">
  How loudly the card announces itself. `urgent` is rate-limited — see [Limits](#limits).
</ParamField>

<ParamField path="actions" type="('approve' | 'redirect' | 'fix')[]">
  What the card offers. `'approve'` gives the user a decision to make; `'fix'` requires `connection`. See [Card kinds](#card-kinds) for how this maps to what the user sees.
</ParamField>

<ParamField path="options" type="{ label: string; description?: string }[]">
  Two to four one-click answers. Labels are capped at 60 characters, descriptions at 200. Anything past the fourth option is dropped.
</ParamField>

<ParamField path="connection" type="{ type: string; name: string }">
  Required with `actions: ['fix']`. `type` is the integration's catalog slug (`'google-calendar'`), `name` is what the user should recognise (`'Google Calendar'`).
</ParamField>

<ParamField path="key" type="string">
  Your idempotency and revision handle. 1–120 characters of `A–Z`, `a–z`, `0–9`, `.`, `_`, `:`, `-`. Push the same `key` again and you land on the existing card instead of knocking a second time. Omit it for one-shot cards.
</ParamField>

<ParamField path="threadId" type="string">
  The conversation the card should hand off into when the user acts on it. Defaults to the current conversation.
</ParamField>

### Returns

<ResponseField name="outcome" type="'deposited' | 'updated' | 'exists' | 'capped'">
  `deposited` — a new card is on the desk.
  `updated` — an existing card with this `key` was revised in place.
  `exists` — a card with this identity was already there, unchanged.
  `capped` — the daily limit was reached and nothing was filed.
</ResponseField>

<ResponseField name="kind" type="'input_request' | 'connection_fix' | 'agent_notice'">
  Which card kind the push routed to.
</ResponseField>

<ResponseField name="key" type="string">
  The key the card is filed under — your `key` if you supplied one, otherwise the generated one. Reuse it to revise the card later.
</ResponseField>

<ResponseField name="reason" type="string">
  Only present on `capped`. A short explanation you can surface to the agent author.
</ResponseField>

## Card kinds

You don't pick the kind directly — it follows from what you ask for, in this order:

<Steps>
  <Step title="Two or more options → a question card">
    Supplying `options` (2–4) files an `input_request`. So does `actions: ['approve']` **without** options — the Approve/Decline pair is synthesized for you, so the card still resolves in one click.
  </Step>

  <Step title="A fix action → a connection card">
    `actions: ['fix']` with `connection: { type, name }` files a `connection_fix` — the card that walks the user through reconnecting the integration your agent is blocked on.
  </Step>

  <Step title="Everything else → a notice">
    Anything with no options and no fix — including `actions: ['redirect']` — files an `agent_notice`: work you finished, something the user should know about.
  </Step>
</Steps>

<Note>
  If you supply both `options` and `actions: ['fix']`, the options win — the question card is checked first.
</Note>

## Limits

Inbox pushes are budgeted so an agent cannot train its user to ignore the inbox.

| Limit                                             | Default    | Behaviour when exceeded                                              |
| ------------------------------------------------- | ---------- | -------------------------------------------------------------------- |
| Cards per agent, per user, per day                | 5 per kind | The push resolves to `{ outcome: 'capped' }` — it does **not** throw |
| `priority: 'urgent'` per agent, per user, per day | 2          | The card still lands, demoted to `high` — never dropped              |

Budgets are shared with the agent's other inbox activity of the same class, so a question pushed from code spends the same allowance as a question the agent asks on its own.

<Warning>
  Treat `capped` as an expected outcome, not an error. Branch on it and fold the information into your run summary instead of retrying:

  ```typescript theme={null}
  const receipt = await User.Inbox.push({ title, body });

  if (receipt.outcome === 'capped') {
    return `Digest ready, but the inbox is full for today: ${receipt.reason}`;
  }
  ```
</Warning>

## Revising a card

Give a card a `key` and later pushes with that same `key` revise it in place rather than filing a second one. The user is not notified again for an unchanged card.

```typescript theme={null}
// Monday — file the card
await User.Inbox.push({
  key: 'weekly-pipeline-review',
  title: 'Pipeline review ready',
  body: '12 deals need a status update.',
});

// Tuesday — same key, new numbers: the existing card updates
await User.Inbox.push({
  key: 'weekly-pipeline-review',
  title: 'Pipeline review ready',
  body: '9 deals need a status update.',
});
// → { outcome: 'updated', kind: 'agent_notice', key: 'weekly-pipeline-review' }
```

## Errors

A full inbox resolves to `capped`. Everything else throws, with a `code` you can branch on:

| `code`            | Meaning                                                                                                                        |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `invalid_input`   | Missing `title`/`body`, a non-http `deeplink`, a `key` outside the allowed charset, or `actions: ['fix']` without `connection` |
| `no_user_context` | The execution context has no user to deliver to                                                                                |
| `deposit_failed`  | The card could not be filed — safe to retry                                                                                    |

```typescript theme={null}
try {
  await User.Inbox.push({ title, body, actions: ['fix'], connection });
} catch (error: any) {
  if (error.code === 'invalid_input') {
    // Fix the payload — retrying unchanged will fail the same way
  }
  throw error;
}
```

## Examples

### Ask for an approval before acting

```typescript theme={null}
import { LuaTool, User } from 'lua-cli';
import { z } from 'zod';

export class RequestRefundApproval implements LuaTool {
  name = 'request_refund_approval';
  description = 'Ask the account owner to approve a refund above the auto-approve threshold';

  inputSchema = z.object({
    orderId: z.string(),
    amount: z.number(),
  });

  async execute({ orderId, amount }: z.infer<typeof this.inputSchema>) {
    const receipt = await User.Inbox.push({
      title: `Approve a $${amount} refund`,
      body: `Order ${orderId} is above the auto-approve limit and needs a decision.`,
      detail: 'Approving issues the refund immediately and emails the customer.',
      options: [
        { label: 'Approve refund', description: 'Issue it now' },
        { label: 'Decline', description: 'Keep the order as is' },
      ],
      key: `refund-approval:${orderId}`,
      priority: 'high',
    });

    if (receipt.outcome === 'capped') {
      return 'Could not file the approval today — the daily inbox limit was reached.';
    }

    return `Approval requested. You'll be asked in your inbox.`;
  }
}
```

### Report finished work from a recurring job

Dynamic jobs carry the user who created them, so a push inside one lands on that user's desk:

```typescript theme={null}
import { Jobs, User } from 'lua-cli';

await Jobs.create({
  name: 'nightly-digest',
  schedule: { type: 'cron', expression: '0 6 * * *' },
  execute: async () => {
    const summary = await buildDigest();

    await User.Inbox.push({
      title: 'Nightly digest ready',
      body: `${summary.count} items processed overnight.`,
      detail: summary.text,
      deeplink: summary.url,
      key: 'nightly-digest',
    });
  },
});
```

Because the `key` is stable, tomorrow's run revises today's card instead of stacking a second one.

### Ask the user to reconnect an integration

```typescript theme={null}
const receipt = await User.Inbox.push({
  title: 'Reconnect Google Calendar',
  body: "I can't read your availability — the calendar connection expired.",
  actions: ['fix'],
  connection: { type: 'google-calendar', name: 'Google Calendar' },
  key: 'calendar-reconnect',
});
// → { outcome: 'deposited', kind: 'connection_fix', key: 'calendar-reconnect' }
```

## Best Practices

<AccordionGroup>
  <Accordion title="Use a stable key for anything recurring">
    A digest, a review, a status card — anything your agent produces on a schedule should carry the same `key` every run. Without one, each run files a new card and the inbox becomes a log.
  </Accordion>

  <Accordion title="Reserve urgent for things that are actually urgent">
    Only two urgent cards per user per day survive at that priority; the rest are demoted. Spending the allowance on routine cards means the genuinely urgent one arrives looking ordinary.
  </Accordion>

  <Accordion title="Give options when you want a decision">
    A notice asking "let me know if this is okay" needs the user to open a conversation and type. Two to four `options` turn the same ask into one click, and the answer comes back into the thread.
  </Accordion>

  <Accordion title="Handle capped, never retry it">
    `capped` means the budget is spent for the day, so an immediate retry fails identically. Put the information in your return value instead — the user still gets it, through the conversation.
  </Accordion>

  <Accordion title="Write the title as the whole message">
    Users scan the first line. `Approve the Q3 renewal quote` is actionable at a glance; `Action required` is not.
  </Accordion>
</AccordionGroup>

## TypeScript Support

The input and receipt are fully typed at the call site, so `receipt.outcome` narrows correctly without any annotation. If you need the shapes as named types, derive them from the method:

```typescript theme={null}
import { User } from 'lua-cli';

type InboxPushInput = Parameters<typeof User.Inbox.push>[0];
type InboxPushReceipt = Awaited<ReturnType<typeof User.Inbox.push>>;

async function notify(input: InboxPushInput): Promise<InboxPushReceipt> {
  return User.Inbox.push(input);
}
```

## Next Steps

<CardGroup cols={2}>
  <Card title="User API" href="/api/user" icon="user">
    Read and write the user data behind the card
  </Card>

  <Card title="Channels API" href="/api/channels" icon="paper-plane">
    Send a message into the conversation instead
  </Card>

  <Card title="Jobs" href="/api/jobs" icon="clock">
    Schedule the work that files the card
  </Card>

  <Card title="Spaces" href="/overview/spaces" icon="users">
    Multi-agent delegation
  </Card>
</CardGroup>
