Sign in to fill these examples with your own keys. Sign in Start free trial

Build an AI agent inbox on PostShiba

Give an agent a real inbox with signed webhooks and reply support.

This guide gives one agent a hosted inbox, stores a signed message, saves an approved draft, and sends one reply.

Before you start

  • A Full access key from API keys for the setup steps. Run them from your shell, not the agent.
  • A Send only key for the agent's reply path in step 6.
  • A sending domain you can verify. A hosted inbox is not a From identity.
  • A payment method on the team. Cluster create returns 403 {error: payment_required} when billing is on and there is no card.

1. Create the cluster

create-cluster.sh
1export CAPSULE_URL="https://app.postshiba.com" 2export CAPSULE_API_KEY="YOUR_API_KEY" 3export TEAM_ID="KjkAJW" 4 5curl -sS \ 6 --request POST \ 7 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/clusters" \ 8 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 9 --header "Content-Type: application/json" \ 10 --data '{ 11 "cluster": { 12 "name": "agent-mail", 13 "size": "small", 14 "region": "manual", 15 "plan": "nano", 16 "classification": "transactional" 17 } 18 }'
cluster-response.json
1{ 2 "id": "NmQpXr", 3 "name": "agent-mail", 4 "region": "manual", 5 "size": "small", 6 "plan": "nano", 7 "ip_assignment": "shared", 8 "desired_status": "active", 9 "status": "active", 10 "sending_ready": true 11}

If sending_ready is still false, GET /api/v1/clusters/NmQpXr again. If the response is 403 kyc_required, the team is still pending. While the team is pending, you send only to member emails, live inbox addresses, and inbox forward-to addresses, and anything else returns 403 kyc_required. See Does PostShiba require production approval?.

2. Create the tenant

create-tenant.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/tenants" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{ 7 "tenant": { 8 "name": "Acme agents", 9 "slug": "acme-agents" 10 } 11 }'
tenant-response.json
1{ 2 "id": "WbLcFd", 3 "name": "Acme agents", 4 "slug": "acme-agents", 5 "suspended": false 6}

Keep the id and the slug. See Tenants.

3. Issue a hosted inbox

Omit sending_domain_id and local_part. PostShiba generates an address on its inbound zone. mx_status is not_required. There is no MX to publish.

create-inbox.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/inboxes" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{ 7 "inbox": { 8 "name": "Acme research agent", 9 "tenant_id": "WbLcFd", 10 "webhook_url": "https://agents.example.com/webhooks/capsule/inbound", 11 "webhook_format": "json" 12 } 13 }'
inbox-response.json
1{ 2 "id": "PqRzMn", 3 "name": "Acme research agent", 4 "address": "inbabc123@inbound.postshiba.com", 5 "local_part": "inbabc123", 6 "host": "inbound.postshiba.com", 7 "tenant_id": "WbLcFd", 8 "webhook_url": "https://agents.example.com/webhooks/capsule/inbound", 9 "webhook_format": "json", 10 "webhook_secret": "SAVE_THIS_SECRET", 11 "mx_status": "not_required" 12}

Save the inbox ID, address, and webhook_secret. Lists omit the secret. See Receiving emails.

4. Verify and store every webhook

Verify the signature, reject a stale timestamp, upsert on the inbound message ID, then enqueue work. Do not run the model inside this request.

inbound-webhook.js
1import { createHmac, timingSafeEqual } from "node:crypto" 2 3function verifyPostShiba({ rawBody, timestamp, signature, secret }) { 4 if (!timestamp || !signature?.startsWith("sha256=")) return false 5 6 const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) 7 if (!Number.isFinite(age) || age > 300) return false 8 9 const expected = createHmac("sha256", secret) 10 .update(`${timestamp}.${rawBody}`) 11 .digest() 12 const received = Buffer.from(signature.slice(7), "hex") 13 14 return received.length === expected.length && 15 timingSafeEqual(received, expected) 16} 17 18export function createAgentInboxHandler({ inbox, store, enqueueOnce }) { 19 return async function handle(request) { 20 const rawBody = await request.text() 21 const timestamp = request.headers.get("X-Capsule-Timestamp") 22 const signature = request.headers.get("X-Capsule-Signature") 23 24 if (!verifyPostShiba({ 25 rawBody, 26 timestamp, 27 signature, 28 secret: inbox.webhookSecret, 29 })) { 30 return new Response("invalid signature", { status: 401 }) 31 } 32 33 const message = JSON.parse(rawBody) 34 if (message.inbox_id !== inbox.capsuleId) { 35 return new Response("wrong inbox", { status: 403 }) 36 } 37 38 await store.upsertInbound({ 39 id: message.id, 40 agentId: inbox.agentId, 41 from: message.from, 42 to: message.to, 43 subject: message.subject, 44 text: message.text, 45 html: message.html, 46 threadId: message.thread_id, 47 inReplyTo: message.in_reply_to, 48 headers: message.headers, 49 envelope: message.envelope, 50 authentication: message.authentication, 51 attachments: message.attachments, 52 }) 53 54 await enqueueOnce(`agent-inbound:${message.id}`, { 55 topic: "agent-inbound", 56 messageId: message.id, 57 }) 58 return new Response(null, { status: 204 }) 59 } 60}

A valid signed body for this inbox returns 204. A bad signature should return 401. A body for another inbox returns 403. See Verify webhook requests.

5. Save an approved draft

Email is untrusted input. After the policy check, pass only approved fields to the model and save the draft.

run-agent.js
1export async function processAgentMail({ 2 messageId, 3 store, 4 model, 5 policy, 6 sendReply, 7}) { 8 const message = await store.getInbound(messageId) 9 const conversation = await store.findConversation({ 10 agentId: message.agentId, 11 threadId: message.threadId, 12 }) 13 14 const decision = await policy.inspect({ 15 sender: message.from, 16 subject: message.subject, 17 text: message.text, 18 attachments: message.attachments.map(({ filename, content_type, size }) => ({ 19 filename, 20 contentType: content_type, 21 size, 22 })), 23 }) 24 25 if (!decision.mayRunAgent) { 26 await store.markNeedsReview(messageId, decision.reason) 27 return 28 } 29 30 const draft = await model.draftReply({ 31 trustedInstructions: conversation.agentInstructions, 32 untrustedEmail: { 33 from: message.from, 34 subject: message.subject, 35 text: message.text, 36 }, 37 allowedTools: decision.allowedTools, 38 }) 39 40 if (decision.requiresHumanApproval) { 41 await store.saveDraft(messageId, draft) 42 return 43 } 44 45 await sendReply({ message, conversation, text: draft.text }) 46}

Set requiresHumanApproval to true until you trust the policy. A saved draft is the row from store.saveDraft. If mayRunAgent is false, you see markNeedsReview instead.

6. Send the approved reply

A hosted inbox is not a From identity. Verify mail.acme.example and issue a credential for this tenant first.

create-domain.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/sending_domains" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{ 7 "sending_domain": { 8 "name": "mail.acme.example", 9 "tenant_id": "WbLcFd" 10 } 11 }'

Publish DNS and wait until sending_allowed is true. See Send basic transactional emails with PostShiba.

create-credential.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/clusters/NmQpXr/smtp_credentials" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{ 7 "smtp_credential": { 8 "tenant_id": "WbLcFd" 9 } 10 }'

The tenant-scoped credential is required so REST can pick a sending-ready cluster that has a live credential for this tenant. Cluster create only issues a default-tenant credential.

credential-response.json
1{ 2 "id": "RvWsXq", 3 "username": "smtp_9", 4 "password": "once-only-password", 5 "tenant_id": "WbLcFd", 6 "tenant": "acme-agents", 7 "cluster_id": "NmQpXr" 8}

Send with the Send only key. Set reply_to to a plus-address so the next inbound message carries your conversation ID in thread_id.

send-agent-reply.js
1import { PostShiba } from "@postshiba/node" 2 3export function createPostShibaSender({ 4 apiKey, 5 teamId, 6 from, 7 inboxAddress, 8 tenant, 9}) { 10 const postshiba = new PostShiba(apiKey, { teamId }) 11 12 return async function sendReply({ message, conversation, text }) { 13 const [local, host] = inboxAddress.split("@") 14 const replyAddress = `${local}+${conversation.id}@${host}` 15 const sourceMessageId = message.headers["message-id"] 16 const references = [ 17 message.headers.references, 18 sourceMessageId, 19 ].filter(Boolean).join(" ") 20 const originalSubject = message.subject || "Your message" 21 22 const body = await postshiba.emails.send({ 23 from, 24 to: [message.from], 25 reply_to: replyAddress, 26 subject: originalSubject.toLowerCase().startsWith("re:") 27 ? originalSubject 28 : `Re: ${originalSubject}`, 29 text, 30 headers: { 31 "In-Reply-To": sourceMessageId, 32 References: references, 33 }, 34 unique_args: { 35 agent_id: conversation.agentId, 36 conversation_id: conversation.id, 37 }, 38 tenant, 39 }) 40 41 if (body.queued !== true) { 42 throw new Error(`PostShiba send failed: ${JSON.stringify(body)}`) 43 } 44 45 return body 46 } 47}
send-response.json
1{ 2 "queued": true, 3 "message_id": "abc@postshiba.com", 4 "from": "Acme Agent <agent@mail.acme.example>", 5 "to": ["you@example.com"], 6 "subject": "Re: Your message", 7 "unique_args": { 8 "agent_id": "agent_1", 9 "conversation_id": "conversation_42" 10 } 11}

If inject queued nobody, the response is 502 {error: inject_failed}. Match agent_id and conversation_id on the delivery webhook. See Event types.

Choose the agent's key

The key you give the agent, in code, MCP, or another automation, sets what a prompt-injected email can make it do.

  • Send only. The agent gets inbound mail from your webhook store and only sends replies. Over MCP it sees only the send tools. A hijacked agent can still send mail from your verified domains, so keep requiresHumanApproval on until you trust the policy.
  • Full access. The agent reads inbound messages or events through the API, or manages inboxes and domains. It can then also read every inbox on the team, change webhooks and domains, and issue SMTP credentials. Use it only when the agent needs those reads, and never let untrusted email choose its tools.

A Send only key cannot read inbound messages or events. If you think a key leaked, delete it on API keys. It stops working right away.

You now have a hosted inbox, a signed store-and-queue handler, a reviewed draft, and one queued reply.

When to move to dedicated IPs

Agent mail is low volume and conversational, so the cluster's shared IPs are usually the right call. Consider a dedicated IP in a transactional pool when you send a steady tens of thousands of messages a month, need a fixed IP a customer can allowlist, or want reputation apart from other senders. Never put replies in a bulk pool. A new dedicated IP warms up for 14 days; overflow keeps replies flowing on shared IPs meanwhile. When you opt in, add "X-Capsule-Pool": "agents" to the headers object in send-agent-reply.js. See Set up IP pools and warm up dedicated IPs.

Next steps