kuiper Docs

Agents

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.

On this page

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

How it fits together#

Output
 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:

Terminal
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:

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

1. The code#

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

Terminal
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
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'));
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:

Terminal
kuiper units create mail-agent
kuiper inbox create concierge --display-name "Acme" --webhook unit://mail-agent/hooks/inbox
Output
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:

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:

Terminal
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#

Terminal
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:

Terminal
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:

Terminal
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 saysWhat it means
mail-agent has no live deployment yetThe unit hasn't been deployed yet. Kuiper keeps trying, so the event arrives once it is live.
mail-agent isn't running anywhereThe 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 matchINBOX_WEBHOOK_SECRET isn't this mailbox's secret. Set it again, then kuiper restart -u mail-agent.
answered 404The path doesn't match. The webhook says /hooks/inbox, and so must the code.
the unit didn't answer in 15sThe 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:

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.

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: threads, HTML, attachments, previews, your own domain and every endpoint.
  • Variables and secrets: how ${{…}} references work.
  • Models: provider keys, fallbacks and the record of every call.