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:
- a unit, deployed from a folder;
- a mailbox whose webhook points at that unit;
- variables and secrets, with the built-in
inboxandmodelsresources; - the model gateway, with your own provider key;
- the SDK and its Glove tools.
How it fits together#
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- Mail arrives for the mailbox. Kuiper stores it and makes a
message.receivedevent. - Kuiper sends the event to the
mail-agentunit, signed with the mailbox's secret. It goes through the machine the unit runs on, so the unit needs no public address. - The unit checks the signature, answers
200at once, and hands the email to a Glove agent. - The agent calls a model through the gateway, then answers with the
reply_emailtool. The answer joins the email's thread.
Before you start#
You need the CLI, signed in, and a project to work in:
kuiper login
kuiper projects create acme-mail
kuiper use acme-mailModels 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:
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// 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'));{
"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.
allowRecipientrefuses 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:
kuiper units create mail-agent
kuiper inbox create concierge --display-name "Acme" --webhook unit://mail-agent/hooks/inboxconcierge+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-D3. Variables#
Point the unit's variables at the secret and the built-in resources:
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
inboxandmodels, so${{inbox.API_KEY}}and${{models.API_KEY}}are the same key. Leave out the reference tomodelsand the key can't call models. - The region's gateway.
INBOX_URLandMODELS_URLare 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#
kuiper deploy --source . -u mail-agent --wait
kuiper logs -u mail-agent -fThe 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:
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:
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:
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_emaillists unread mail, andread_emailmarks 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_URLandMODELS_URLas in step 3, plusMAILBOX, the mailbox's address. A Foundry unit deploys with nokuiper.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.