# Example: an email agent

> Put the pieces together. Deploy a Glove agent that has its own email address, hears about new mail through a webhook to its unit, and answers in the thread with a model from Kuiper's gateway.

Web page: https://kuiper.sh/docs/email-agent/

This page builds one small agent end to end. It touches most of what an agent on Kuiper uses:

- a unit, deployed from a folder;
- a [mailbox](https://kuiper.sh/docs/inbox.md) whose webhook points at that unit;
- [variables and secrets](https://kuiper.sh/docs/variables.md), with the built-in `inbox` and `models` resources;
- the [model gateway](https://kuiper.sh/docs/models.md), with your own provider key;
- the [SDK](https://kuiper.sh/docs/sdk.md) and its Glove tools.

## How it fits together

```text
 someone ── email ──▶ concierge+43223319@agent-inbox.kiprsh.app
                                    │
                         Kuiper stores the mail
                                    │
              signed POST /hooks/inbox, through the unit's node
                                    ▼
                      mail-agent (a unit, no public address)
                                    │
               Glove agent: model calls ──▶ gateway /models
                            reply_email ──▶ gateway /inbox
                                    │
                         the answer, in the same thread
```

1. Mail arrives for the mailbox. Kuiper stores it and makes a `message.received` event.
2. Kuiper sends the event to the `mail-agent` unit, signed with the mailbox's secret. It goes through the machine the unit runs on, so the unit needs no public address.
3. The unit checks the signature, answers `200` at once, and hands the email to a Glove agent.
4. The agent calls a model through the gateway, then answers with the `reply_email` tool. The answer joins the email's thread.

## Before you start

You need the CLI, signed in, and a project to work in:

```sh
kuiper login
kuiper projects create acme-mail
kuiper use acme-mail
```

Models are called with your organisation's own provider keys. Add one if you haven't yet, for example Anthropic's:

```sh
printf %s "$ANTHROPIC_API_KEY" | kuiper providers add anthropic --key -
```

## 1. The code

Make a folder with three files, and install the packages:

```sh
mkdir mail-agent && cd mail-agent
npm init -y && npm pkg set type=module scripts.start="node server.mjs"
npm install @kuiper-sh/inbox @kuiper-sh/models glove-core
```

```js server.mjs
// An agent with an email address. Kuiper POSTs each new email to
// /hooks/inbox, and a Glove agent answers it in its thread.
import { createServer } from 'node:http';
import { Inbox, verifyWebhook } from '@kuiper-sh/inbox';
import { inboxTools, emailRequest } from '@kuiper-sh/inbox/glove';
import { Models } from '@kuiper-sh/models';
import { MemoryStore } from 'glove-core';
import { Glove } from 'glove-core/glove';
import { Displaymanager } from 'glove-core/display-manager';
import { createAdapter } from 'glove-core/models/providers';

// One gateway key opens both services: the unit's variables name both.
const apiKey = process.env.GATEWAY_KEY;
const inbox = new Inbox({ apiKey, gatewayUrl: process.env.INBOX_URL });
const models = new Models({ apiKey, gatewayUrl: process.env.MODELS_URL });
const secret = process.env.INBOX_WEBHOOK_SECRET ?? '';
const handled = new Set();

async function answer(mailbox, message) {
  const sender = message.from?.address.toLowerCase();
  // Auto-replies, mailing lists and bounces are `automated`: answering them
  // can start two agents answering each other forever.
  if (!sender || message.automated) return;
  const agent = new Glove({
    store: new MemoryStore(message.thread_id),
    model: createAdapter({ provider: 'anthropic', model: 'claude-sonnet-5-5', ...models.glove('anthropic') }),
    displayManager: new Displaymanager(),
    systemPrompt:
      'You answer email for Acme. Read the email, then answer it once with reply_email. ' +
      'Be brief. If you cannot help, say who can.',
    compaction_config: { compaction_instructions: 'Summarise the email thread so far.' },
  });
  // The agent may write only to whoever wrote to it.
  for (const tool of inboxTools(inbox, { mailbox, allowRecipient: (a) => a === sender })) agent.fold(tool);
  await agent.build().processRequest(emailRequest(message));
  await inbox.messages.markRead(message.id);
}

createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/hooks/inbox') {
    res.writeHead(404).end();
    return;
  }
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  let event;
  try {
    event = await verifyWebhook(Buffer.concat(chunks), req.headers['kuiper-signature']?.toString(), secret);
  } catch (e) {
    res.writeHead(400).end(e instanceof Error ? e.message : 'invalid webhook');
    return;
  }
  // Answer at once: Kuiper waits 15 seconds, and an agent takes longer.
  res.writeHead(200).end('ok');
  // A delivery that timed out comes again with the same event ID.
  if (event.type !== 'message.received' || handled.has(event.id)) return;
  handled.add(event.id);
  const message = event.data.message;
  answer(event.mailbox.id, message).catch((e) => console.error(`answering ${message.id}:`, e));
}).listen(8080, () => console.log('listening on 8080'));
```

```json kuiper.json
{
  "schemaVersion": 1,
  "project": "mail-agent",
  "runtime": { "entrypoint": ["node", "server.mjs"] },
  "ports": [{ "name": "http", "port": 8080 }],
  "env": {
    "required": ["GATEWAY_KEY", "INBOX_URL", "MODELS_URL", "INBOX_WEBHOOK_SECRET"]
  },
  "resources": {
    "request": { "cpu": 0.25, "memoryMiB": 256 },
    "limit": { "cpu": 1, "memoryMiB": 512 }
  },
  "health": { "tcp": true }
}
```

- **The port is private.** It has no `"public": true`, so the unit gets no address on the internet. Kuiper reaches it anyway, through its node.
- **The webhook handler answers before the agent works.** Kuiper waits 15 seconds for an answer and then sends the event again. The handler checks the signature, answers `200`, and lets the agent run afterwards.
- **Each event is handled once.** Repeated deliveries of an event share its `id`. The handler keeps the IDs it has seen in memory. A unit that must never answer twice, even across restarts, keeps them in a database.
- **The agent can only answer the sender.** `allowRecipient` refuses any other address, so an email can't talk the agent into writing to someone else.
- **One unit can serve several mailboxes.** The tools take the mailbox from the event.

Commit `package-lock.json` with the rest. Kuiper builds a Node app without a Dockerfile, installs from the lockfile, and starts the entrypoint in `kuiper.json`.

## 2. The unit and the mailbox

Create the unit first, so the mailbox's webhook can name it:

```sh
kuiper units create mail-agent
kuiper inbox create concierge --display-name "Acme" --webhook unit://mail-agent/hooks/inbox
```

```text
concierge+43223319@agent-inbox.kiprsh.app
webhook: unit://mail-agent/hooks/inbox
webhook secret (shown once): whsec_…
```

`unit://mail-agent/hooks/inbox` means: the unit `mail-agent`, on its main port, at `/hooks/inbox`. Name a port after a colon if the unit has several: `unit://mail-agent:http/hooks/inbox`. Kuiper follows the unit when it redeploys, moves to another machine or is renamed.

Keep the secret as a project secret:

```sh
kuiper secrets set INBOX_WEBHOOK_SECRET=-     # paste whsec_…, press Enter, then Ctrl-D
```

## 3. Variables

Point the unit's variables at the secret and the built-in resources:

```sh
kuiper env set -u mail-agent \
  'GATEWAY_KEY=${{inbox.API_KEY}}' \
  'INBOX_URL=${{inbox.BASE_URL}}' \
  'MODELS_URL=${{models.BASE_URL}}' \
  'INBOX_WEBHOOK_SECRET=${{secrets.INBOX_WEBHOOK_SECRET}}'
```

- **One key for both services.** Each running copy of the unit gets one gateway key. It opens the services the unit's variables name, here `inbox` and `models`, so `${{inbox.API_KEY}}` and `${{models.API_KEY}}` are the same key. Leave out the reference to `models` and the key can't call models.
- **The region's gateway.** `INBOX_URL` and `MODELS_URL` are the gateway in the unit's region.
- **Nothing to rotate.** The key changes on every deploy and is revoked when the old copy stops.

## 4. Deploy

```sh
kuiper deploy --source . -u mail-agent --wait
kuiper logs -u mail-agent -f
```

The log shows `listening on 8080` once it is live.

## 5. Send it an email

From any email address, if your Kuiper receives mail from the internet. Or from a second mailbox, since mail between Kuiper's mailboxes always works:

```sh
kuiper inbox create tester
kuiper inbox send tester --to concierge+43223319@agent-inbox.kiprsh.app \
  --subject "Where is my order?" --text "Hi, where is order 1234?"
```

A few seconds later the answer is in `tester`, in the same thread:

```sh
kuiper inbox messages tester
kuiper inbox read msg_…
```

The project's **Inbox** page shows both mailboxes and the thread. The `concierge` mailbox's settings list each webhook event, and what the unit answered.

## When something doesn't arrive

The mailbox's webhook events say what happened to each delivery:

| What it says | What it means |
| --- | --- |
| `mail-agent has no live deployment yet` | The unit hasn't been deployed yet. Kuiper keeps trying, so the event arrives once it is live. |
| `mail-agent isn't running anywhere` | The unit is stopped or crashing. See `kuiper logs -u mail-agent`. |
| `mail-agent:8080: …` | The unit's machine couldn't reach the app on that port. Check the port in `kuiper.json`. |
| `answered 400: the signature does not match` | `INBOX_WEBHOOK_SECRET` isn't this mailbox's secret. Set it again, then `kuiper restart -u mail-agent`. |
| `answered 404` | The path doesn't match. The webhook says `/hooks/inbox`, and so must the code. |
| `the unit didn't answer in 15s` | The handler waited for the agent. Answer first, as above. |

An event that isn't answered `2xx` is sent again for about a day.

## In a Glove Foundry app

A Foundry agent takes the same tools. Return them from the agent's `tools`:

```ts agents/concierge/agent.ts
import { defineAgent, defineSchedule } from 'glove-foundry';
import { createAdapter } from 'glove-core/models/providers';
import { Inbox } from '@kuiper-sh/inbox';
import { inboxTools } from '@kuiper-sh/inbox/glove';
import { Models } from '@kuiper-sh/models';

const apiKey = process.env.GATEWAY_KEY;
const inbox = new Inbox({ apiKey, gatewayUrl: process.env.INBOX_URL });
const models = new Models({ apiKey, gatewayUrl: process.env.MODELS_URL });

const checkMail = defineSchedule({
  name: 'check-mail',
  timing: { kind: 'every', interval: '5m' },
  message: 'Check your mailbox with check_email. Read each new email with read_email and answer it once with reply_email.',
});

export default defineAgent({
  description: 'Answers email for Acme',
  model: createAdapter({ provider: 'anthropic', model: 'claude-sonnet-5-5', ...models.glove('anthropic') }),
  systemPrompt: 'You answer email for Acme. Be brief. Never answer an email marked automated.',
  tools: inboxTools(inbox, { mailbox: process.env.MAILBOX ?? 'concierge' }),
  schedules: [checkMail],
});
```

- **Foundry takes requests through its own API**, behind your authorizer, so it has no route for a webhook. The agent checks its mailbox on a schedule instead. `check_email` lists unread mail, and `read_email` marks an email read, so nothing is answered twice.
- **Schedules belong to an agent's instances.** See Glove's docs on agent-local schedules.
- **The variables are the same:** `GATEWAY_KEY`, `INBOX_URL` and `MODELS_URL` as in step 3, plus `MAILBOX`, the mailbox's address. A Foundry unit deploys with no `kuiper.json`; see [Glove Foundry units](https://kuiper.sh/docs/foundry.md).

To answer the moment mail arrives, point the webhook at a small unit like the one above, and have it start the Foundry run.

## Next

- [Inbox](https://kuiper.sh/docs/inbox.md): threads, HTML, attachments, previews, your own domain and every endpoint.
- [Variables and secrets](https://kuiper.sh/docs/variables.md): how `${{…}}` references work.
- [Models](https://kuiper.sh/docs/models.md): provider keys, fallbacks and the record of every call.
