Skip to documentation
Documentation

Cloud-served agents

Write one config for your agent, then let Keiki run everything.

A cloud-served agent is hosted by Keiki. You write one config with the agent's instructions, model and feature toggles, then call kit.sync(). Keiki runs the whole loop for you and handles delivery, billing, memory, browsing and code execution across every channel the agent speaks on. You do not run a server.

TypeScript
await kit.sync() // push config, tools and plugins to Keiki; Keiki runs everything

Quick start

Shell
bun add @onkeiki/agents
TypeScript
// src/index.ts
import { Effect } from 'effect'
import { AgentKit } from '@onkeiki/agents'

const kit = AgentKit.create({
  apiKey: process.env.KEIKI_API_KEY!,
  name: 'My Agent',

  resolveNumber: (ctx) => Effect.succeed(process.env.SMS_LINE!),

  agent: {
    model: '<model-id>',
    maxSteps: 10,
    historyLimit: 40,

    systemPrompt: ({ user, now, context }) => [
      `You are a helpful assistant.`,
      `Current time: ${now.toISOString()}`,
      context.memorySummary ? `\nAbout this user:\n${context.memorySummary}` : '',
    ].join('\n'),

    tools: [],
  },
})

await kit.sync()
Shell
bun run src/index.ts
# Config synced to Keiki. Keiki now runs your agent.

Channels

The same config serves SMS, Telegram, Slack, email and WhatsApp. You add channels in the dashboard, one identity per iMessage line, bot token or inbox. The config itself stays free of channels, apart from resolveNumber, which picks the outbound SMS line.

Tools, plugins, memory, steering and billing all sit above the channel layer, so adding a channel never means rewriting the agent.

Phone calls, SMS/RCS and A2A (early access)

Phone calls, SMS/RCS and A2A (agent-to-agent communication) are not live yet. Request early access from Channels → Add channel → Phone calls, SMS/RCS or A2A, or from an agent editor's Channels section, including while creating a draft. Tell us your purpose and expected monthly call or message volume; you can update each request later, and the team will follow up.

iMessage

A threaded reply is answered as what the contact wrote, and the agent also sees the message they replied to. A photo and the text sent with it in one go arrive as a single message: the agent reads the text and the attachment together.

On a dedicated iMessage line bought in Keiki, the agent can also send screen and bubble effects, reply to a specific message in the thread, send a voice note that plays in a bubble (sms_send_file with voice), and use app cards when the iMessage Apps add-on is on.

The agent sees each contact message's id, so it can leave a tapback on it or answer it as a threaded reply. Tapbacks, effects and threaded replies work on every iMessage line, imported lines included; polls and voice notes work on iMessage lines, and app cards need the iMessage Apps add-on plus a dedicated line bought in Keiki.

If no iMessage device receives a message within about 10 minutes, the conversation on the dashboard shows it as Not delivered.

Photos, videos, voice notes and files sent to an iMessage line are stored the same way as WhatsApp attachments (see Attachments): the annotation carries attachment_id, mime and size next to the description, and a webhook tool called with that ID gets a context.attachments[<id>].url to fetch the original file. Code tools get the same entry as ctx.attachments[<id>].

WhatsApp

A reply doesn't end the turn. The agent can say "one moment, I'm searching" and keep calling tools in the same turn, as it does on every other channel.

A photo or file and the text sent alongside it are answered together after the attachment has been read; an unsent reply with no tools run is redone when another message arrives.

A reply that quotes an earlier message reaches the agent with the quoted text, and the SDK receives it as replyToContent alongside replyToMessageId. WhatsApp sends only the quoted message's ID, so Keiki looks up its text in the conversation. A quote of a message Keiki never saw, such as one sent from another app, arrives with the ID only.

A reaction is feedback, not a message. It never starts a turn, so the agent doesn't answer a ❤️ with another one. It is saved as a feedback signal on the message it was left on, not shown in the conversation. A negative one such as 👎 counts in Improvements as the contact rejecting that reply. Reactions work the same way on every iMessage line.

Keiki tracks a delivery receipt for every message the agent sends: sent, delivered, read or failed. A failed message shows Not delivered under its bubble in the conversation, with WhatsApp's error code and reason (for example 131047 Re-engagement message). Receipts arrive on the same messages webhook field as inbound messages, so a Meta number needs no extra subscription. Kapso numbers are subscribed to receipt events automatically.

Contacts who hide their number

A WhatsApp user who turns on a username can write without sharing their phone number. Meta then names them only by their business-scoped user ID (BSUID), such as EG.2102026660705306. Webhook tools receive it on every call as context.userId and in an X-Orchid-User-Id header, authored tools read it as ctx.channel.businessScopedUserId, and the escalation webhook carries it as userId. This happens whether or not a phone number came with the message. context.phone stays wa: plus the contact's number whenever Meta sends one. For a contact with no number it is wa: plus their BSUID, and replies go to that BSUID. A contact who wrote from their number before turning on a username stays in the same conversation.

Attachments

When durable storage is available, an inbound WhatsApp media annotation includes attachment_id, media_id, mime and size, along with any filename and extracted description. A webhook tool called with an attachment ID receives a context.attachments[<id>] entry with filename, mime_type, size, sha256 and a url that is valid for 15 minutes; code tools get the same entry as ctx.attachments[<id>]. Attachments are kept for 90 days by default (ATTACHMENT_RETENTION_DAYS) and removed when conversation history is cleared. The bytes live in the platform's object store (OBJECT_STORE_BUCKET, an S3 bucket only the server can reach); without one the conversation's Archil disk is used, and without either, annotations keep the media_id but have no attachment_id.

Steered turns

A turn someone on your team steers from the dashboard runs with the conversation's real history and tools, but nothing it does reaches the contact. A webhook tool called from such a turn receives X-Orchid-Internal: true and context.internal: true, and context.messages holds what the team member typed, because that message never arrived through the channel. A tool that keeps its own record of inbound messages should read context.messages in that case instead of concluding the contact said nothing new.

Telegram group chats

In a Telegram group, the bot answers only when someone addresses it. That means an @-mention of its handle, a reply to one of its own messages, a slash command such as /cmd or /cmd@thisbot, or its display name leading the message. Commands aimed at another bot are ignored. Everything else is read quietly and kept as context, so staying silent does not mean losing the thread. Group messages reach the model labelled with the sender's name, and the whole group shares one conversation, so members share history and memory. Private chats are unchanged, and every message there is answered under the sender's own conversation.

You can change this per identity:

JSON
{ "replyMode": "always", "identity": "user", "mentionAliases": [] }

mentionAliases defaults to the bot's display name. Set it to [] to require a real @-mention, or list your own aliases. A value of null restores the default. With replyMode: "always" the bot answers every group message. With identity: "user" each member gets their own conversation.

The SDK's telegram() plugin does the same for agents you host yourself:

TypeScript
telegram({
  botToken: process.env.TG_BOT_TOKEN!,
  groupReplyMode: 'mention',        // 'always' answers every group message
  mentionAliases: ['roomie'],       // extra leading names that address the bot
  groupIdentity: 'chat',            // 'user' splits members into separate conversations
  allowedChatIds: [-1001234567890], // optional allowlist
})

Mention-gating is the default. A group that used to answer every message needs replyMode: "always" to keep doing so.

iMessage app cards

The iMessage Apps add-on lets tools open an HTTPS page as a card on an eligible Keiki-bought dedicated line. See iMessage Apps for setup, tool inputs and delivery results.

Add your own tools

TypeScript
import { defineTool } from '@onkeiki/agents'
import { Schema, Effect } from 'effect'

const checkWeather = defineTool({
  name: 'check_weather',
  description: 'Get current weather for a city',
  input: Schema.Struct({ city: Schema.String }),
  execute: (input) =>
    Effect.tryPromise(async () => {
      const res = await fetch(`https://api.example.com/weather?city=${encodeURIComponent(input.city)}`)
      return (await res.json()) as { tempC: number }
    }),
})

const searchFlights = defineTool({
  name: 'search_flights',
  description: 'Search for flights between two cities',
  input: Schema.Struct({ from: Schema.String, to: Schema.String, date: Schema.String }),
  execute: (input, ctx) =>
    Effect.gen(function* () {
      const result = yield* ctx.platform.browse(
        `Search flights from ${input.from} to ${input.to} on ${input.date}`
      )
      return { flights: result.content }
    }),
})

const kit = AgentKit.create({
  // ...
  agent: { tools: [checkWeather, searchFlights] },
})

Managed features

Turn a feature on in config, and Keiki runs the infrastructure behind it.

TypeScript
const kit = AgentKit.create({
  // ...
  agent: {
    model: '<model-id>',
    maxSteps: 10,
    historyLimit: 40,
    systemPrompt: ({ user, now, context }) => `...`,
    tools: [checkWeather],

    // Per-user memory: facts, preferences, context recall
    memory: {
      enabled: true,
      autoRetrieve: true,
      categories: ['personal', 'work', 'preference', 'context'],
    },

    // Cloud browser, with a handoff to a person when needed
    browser: { enabled: true, handoff: true, screenshotDelivery: true },

    // Persistent code sandbox
    sandbox: { enabled: true, persistent: true, maxExecTime: '5 minutes' },

    // Background job runner
    executor: { enabled: true, maxConcurrent: 3, timeout: '10 minutes', onComplete: 'notify' },

    // State shared across the steps of one turn
    blackboard: true,

    // Cache breakpoints to cut inference cost
    promptCache: true,

    // Send a conversation to a person
    escalation: {
      enabled: true,
      requireConfirmation: true,
      routes: {
        webhook: 'https://my-app.com/api/escalations',
        slack: { channel: '#support' },
        email: 'support@mycompany.com',
      },
    },

    // Gather rapid messages, and interrupt a turn when a new one lands
    steering: { debounceWindow: '2 seconds', midTurnStrategy: 'interrupt', maxBatch: 5 },

    // Handle attachments before the agent sees them
    media: {
      audio: { action: 'transcribe' },
      image: { action: 'describe' },
      video: { action: 'describe' },
      document: { action: 'extract' },
      fallback: '[Unreadable attachment]',
    },
  },

  // Lifecycle hooks
  events: {
    onInbound: (msg) => Effect.succeed({ proceed: true }),
    onOutbound: (msg) => Effect.tryPromise(() => analytics.track('sent', msg)),
    onNewUser: (event) => Effect.tryPromise(() => slack.post('#signups', event.phone)),
    onOptOut: (event) => Effect.tryPromise(() => crm.markOptedOut(event.phone)),
  },
})

Plugins

Plugins are the main way to extend an agent with reusable abilities. Each one is self-contained. It owns its config and contributes tools, hooks, routes and layers without touching the core agent config.

Shell
bun add @onkeiki/agents-plugins
TypeScript
import { AgentKit } from '@onkeiki/agents'
import { memory, browser, steering, media, observability, alerts } from '@onkeiki/agents-plugins'

const kit = AgentKit.create({
  apiKey: process.env.KEIKI_API_KEY!,
  name: 'My Agent',

  plugins: [
    memory({ autoRetrieve: true }),
    browser({ handoff: true, screenshotDelivery: true }),
    steering({ debounceWindow: '2 seconds', midTurnStrategy: 'interrupt', maxBatch: 5 }),
    media({ audio: 'transcribe', image: 'describe', video: 'describe', document: 'extract' }),
    observability({ exporters: [braintrustExporter({ apiKey: '...', projectName: 'my-agent' })] }),
    alerts({ alerts: [dailyBriefing] }),
  ],

  agent: {
    model: '<model-id>',
    maxSteps: 10,
    historyLimit: 40,
    systemPrompt: () => `...`,
    tools: [myCustomTool],  // only your custom tools here
  },
})

await kit.sync()

Built-in plugins

PluginWhat it does
memory(opts)Per-user memory. It retrieves facts before each turn and extracts new ones after.
browser(opts)Cloud browser, with a handoff to a person and screenshot delivery.
sandbox(opts)Persistent code sandbox.
executor(opts)Background job runner with concurrency limits.
steering(opts)Gathers rapid messages, and interrupts, appends or queues mid-turn.
media(opts)Handles inbound attachments: transcribe audio, describe images, extract documents.
escalation(opts)Always queues a conversation in the dashboard Handoffs queue; configured webhook, Slack or email routes add notifications.
observability(opts)Exports traces to Braintrust, Axiom, OpenTelemetry or a backend of your own.
alerts(opts)Scheduled or triggered broadcasts, with de-duplication.
events(handlers)Lifecycle handlers: onInbound, onOutbound, onNewUser, onOptOut.
mcp(opts)Lets people add MCP servers and OpenAPI specs at runtime. Requires sandbox().

Write a plugin

A plugin satisfies the AgentKitPlugin interface. Use definePlugin() for type safety.

TypeScript
import { Effect } from 'effect'
import { definePlugin } from '@onkeiki/agents'

export const analytics = (opts: { posthogKey: string }) =>
  definePlugin({
    id: 'analytics',
    hooks: {
      afterTurn: (result) =>
        Effect.tryPromise(() =>
          posthog.capture('sms_turn', {
            phone: result.phone,
            toolCalls: result.toolCalls.length,
            tokensUsed: result.tokensUsed,
            durationMs: result.durationMs,
          })
        ),
    },
  })

MCP plugin

The mcp() plugin lets the agent, and the people talking to it, connect tools at runtime. It runs inside the agent's sandbox and exposes two tools:

  • mcp_add_source connects a remote MCP server by URL, or a REST API by its OpenAPI spec;
  • mcp_execute runs JavaScript against the connected sources' tools.

Sources with no auth connect on their own. Sources that need an API key or OAuth return a link the user opens once to authorize. The plugin requires sandbox() and throws at boot without it.

TypeScript
import { sandbox, mcp } from '@onkeiki/agents-plugins'

const kit = AgentKit.create({
  // ...
  plugins: [
    sandbox({ persistent: true }),
    mcp({
      maxServers: 5,
      userCanAdd: true,
      servers: [{ url: 'https://my-internal-mcp.example.com/mcp' }],
    }),
  ],
})

Plugin interface

TypeScript
type AgentKitPlugin = {
  id: string

  // Called once at boot. It may return partial config overrides.
  init?: (ctx: PluginContext) => PluginInitResult | void

  // Tools contributed to the agent
  tools?: ToolDefinition[]

  // HTTP routes, for webhooks and alert execution
  routes?: Record<string, (req: Request, ctx: RouteContext) => Promise<Response>>

  // Lifecycle hooks
  hooks?: {
    beforeTurn?: (turn: TurnEvent) => Effect<TurnEvent>
    afterTurn?: (result: TurnResult) => Effect<void>
    beforeToolCall?: (call: ToolCallEvent) => Effect<ToolCallEvent>
    afterToolCall?: (result: ToolCallResult) => Effect<ToolCallResult>
  }

  // Global request and response interceptors
  onRequest?: (req: Request) => Effect<Request | Response | void>
  onResponse?: (res: Response) => Effect<Response>

  // Effect service layers
  layers?: Layer[]

  // Add to the sync payload
  onSync?: (payload: SyncPayload) => SyncPayload

  // Plugin-owned storage migrations, additive only and never dropped
  migrations?: PluginMigration[]

  // Path-matched middleware
  middleware?: { path: string; handler: (req, ctx) => Effect<Response | void> }[]
}

The plugins field is optional. Configs that set features directly on agent, such as memory: { enabled: true }, keep working. Plugins are additive.

Observability

Send turn traces to your own observability platform. Keiki always records traces internally and keeps them for 14 days. Exporters send copies to external services.

TypeScript
import { braintrustExporter, axiomExporter, otelExporter, customExporter } from '@onkeiki/agents'

observability: {
  enabled: true,
  exporters: [
    braintrustExporter({ apiKey: process.env.BRAINTRUST_API_KEY!, projectName: 'my-agent' }),
    axiomExporter({ apiToken: process.env.AXIOM_TOKEN!, dataset: 'my-agent-traces' }),
    otelExporter({
      endpoint: process.env.OTEL_ENDPOINT!,      // e.g. https://tempo.myinfra.com
      headers: { 'X-Scope-OrgID': 'my-org' },
      serviceName: 'my-agent',
    }),
  ],
  sampleRate: 0.5,       // Export 50% of traces. Keiki keeps 100%.
  redactInputs: true,    // Strip PII from span inputs before export
  redactOutputs: false,  // Keep outputs, which helps when debugging
}

A custom exporter is a name plus an exportFn(trace) pointed at your own endpoint.

Storage

For a cloud agent, Keiki stores message history, users and metering, so your config needs no storage setup. If you run the loop yourself, you supply a storage adapter. The SDK ships adapters for the common databases, and you can implement a Storage backend of your own.

Phone numbers

You can assign phone numbers per person, or spread the load across a pool.

TypeScript
const kit = AgentKit.create({
  // ...
  resolveNumber: (ctx) =>
    Effect.gen(function* () {
      // Check whether the user already has a sticky line
      const existing = yield* ctx.platform.getUserMeta(ctx.phone, 'assigned_line')
      if (existing) return existing

      const line = yield* ctx.platform.acquireLine({
        pool: 'us-west',
        strategy: 'least-active',
        sticky: true,
      })

      yield* ctx.platform.setUserMeta(ctx.phone, 'assigned_line', line.number)
      return line.number
    }),
})

Alerts

Alerts run on a schedule and broadcast to subscribers.

TypeScript
import { defineAlert } from '@onkeiki/agents'
import { Effect } from 'effect'

const dailyBriefing = defineAlert({
  name: 'daily_briefing',
  description: 'Morning summary of tasks and weather',
  schedule: { type: 'cron', expression: '0 7 * * *', timezone: 'America/New_York' },
  dedupKey: (ctx) => `briefing-${ctx.now.toISOString().slice(0, 10)}`,
  shouldSend: (ctx) => Effect.succeed(ctx.subscriber.preferences.dailyBriefing !== false),
  generate: (ctx) =>
    Effect.succeed({ text: `Good morning! Here's your briefing for ${ctx.now.toLocaleDateString()}...` }),
})

const kit = AgentKit.create({
  // ...
  agent: { alerts: [dailyBriefing] },
})

Developer REST API

You can manage your agent over REST, using your API key.

/config and /tools act on one agent. A key bound to an agent always acts on that agent. An org-wide key can act on any agent in your organization by naming it with agent_id: in the query for GET and DELETE, and in the JSON body otherwise. GET /agents lists the agents a key can name. If an org-wide key names no agent, /config acts on the key's only active agent, and returns 400 with the key's agents when it has more than one. Until the key has an agent, /config reads and writes the key-level config (agent_id: null in the response). /tools without agent_id uses the key's most recently active agent. A PATCH only changes the fields it sends, and features only changes the flags it names. escalation_routes must point at your organization's own webhooks and Slack workspaces. steering_config applies to every agent on the key.

A key bound to an agent only reaches that agent. Outcomes, alerts, traces and harness turns are held to it, and a sibling agent's record answers 404. What belongs to the whole organization answers 403 (An org-wide API key is required): /api/v1/keys, /api/v1/orgs, /api/v1/billing, /audit, /usage, /people, the line routes (/phones/* and line provisioning), steering_config, and every secret write (PUT and DELETE /secrets/:name, /secrets/:name/proxy, /secrets/sync). GET /secrets still lists secret names. /sync from a bound key answers 403 for secrets or a capability with a runtime to publish, and 409 for an alert name another agent already uses.

Shell
# List the agents this key can act on
curl -H "Authorization: Bearer $API_KEY" https://<your-host>/api/v1/developer/agents

# Read agent config (org-wide key: add ?agent_id=<agent-id>)
curl -H "Authorization: Bearer $API_KEY" https://<your-host>/api/v1/developer/config

# Update the system prompt; other fields keep their values
curl -X PATCH -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_id": "<agent-id>", "system_prompt": "You are..."}' \
  https://<your-host>/api/v1/developer/config

# Upload a custom tool that Keiki calls over a webhook
curl -X POST -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "check_weather",
    "description": "Get weather for a city",
    "inputSchema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
    "executionType": "webhook",
    "webhookUrl": "https://my-app.com/api/tools/weather"
  }' \
  https://<your-host>/api/v1/developer/tools

# Acquire a phone number
curl -X POST -H "Authorization: Bearer $API_KEY" \
  -d '{"pool": "us-west", "strategy": "least-active"}' \
  https://<your-host>/api/v1/developer/phones/acquire

# Check usage
curl -H "Authorization: Bearer $API_KEY" https://<your-host>/api/v1/developer/usage

A webhook tool receives { "input": {...}, "context": {...} } and returns JSON, which the agent reads as the tool's result. If the tool sends the contact something itself, from your own backend (a card, a document), add delivered to the result: a short description of what the contact received, or an array with one per message (up to 10, each kept to its first 2,000 characters). Keiki records each as a message the agent sent in that turn. Later turns can then read it in their history and find it with conversation_search, and the dashboard shows it in the conversation. Without it, the transcript only has the reply that came after.

JSON
{
  "cardDelivery": { "state": "accepted" },
  "delivered": "Job card: Growth Marketing Manager, iGaming (Remote) at SmartRecruitment.com"
}

When a handoff closes

Every escalation opens a handoff in the dashboard Handoffs queue. While nobody has claimed it, the agent keeps answering the contact, so it can still solve the problem itself. A handoff closes in one of these ways:

  • A teammate resolves it or hands it back, from the dashboard or the Slack thread.
  • The agent closes it with the resolve_escalation tool, when it solved the problem without a person (for example, it sent the sign-in link the contact needed). It passes a short summary, which the transcript shows on the handoff's resolved marker. It only closes handoffs the agent opened itself, not a takeover a teammate started.
  • It goes stale. A handoff nobody claims closes itself 7 days after it was opened or last escalated again, closed by system.

When the agent closes a handoff, its Slack thread is closed with the agent's summary. A stale handoff's thread is closed when the agent next escalates that conversation, and the new escalation gets a fresh thread.

Once a teammate claims a handoff, it stays with them: the agent is silent, resolve_escalation refuses to close it, and it never goes stale. It only ends when the teammate hands it back or resolves it.

Hand work to a person

You can hand any kind of work to an operator, whether or not the agent escalated it. Keiki announces it in the agent's Slack destination as a thread, with the buttons your request supplied. It needs the agent's operator-handoff capability. If the response says announced: false, the thread exists but no Slack install is configured to show it.

Shell
curl -X POST -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup_9f2" \
  -d '{
    "workType": "waitlist_review",
    "subject": {
      "provider": "keiki",
      "connectionId": "wh_7",
      "resourceType": "waitlist_entry",
      "resourceId": "e_412"
    },
    "title": "Waitlist review",
    "summary": "Ada, 4, mornings",
    "details": [{"label": "Signed up", "value": "today"}],
    "actions": [
      {
        "key": "approve",
        "label": "Approve",
        "style": "primary",
        "operation": { "type": "webhook.invoke", "webhookId": "wh_7" }
      },
      {
        "key": "open_ticket",
        "label": "Open a ticket",
        "operation": {
          "type": "capability.tool",
          "capability": "zendesk",
          "tool": "create_ticket",
          "arguments": { "requester_id": "91" }
        }
      }
    ]
  }' \
  https://<your-host>/api/v1/developer/handoffs

Send conversation instead of subject to hand over one of the agent's own conversations. That thread also offers reply (which takes the conversation over) and hand back, and operators can answer straight from it (Handoff threads). Anything else is named by reference and gets no conversation controls.

A reply a person sends while they hold a conversation, from that Slack thread or from a dashboard takeover, stays in the agent's history as sent by the team, not by the agent. Once the conversation is handed back, the agent reads that reply as something the contact was told by a person, so it doesn't take it back or apologise for it as its own. The history the SDK receives marks those messages the same way, and conversation_search returns them with role team.

Each action names something your organization already has, never a URL in the request. webhook.invoke names one of your saved endpoints. capability.tool names an installed capability and one of its tools, so a button runs the same operation the agent runs, pressed by a person instead. Where two publishers use the same slug, write capability as "publisher/slug". An action is delivered at most once when someone presses it, and the click is recorded. Add "repeatable": true for a button you want pressed more than once.

To collect something before the call, send form as a JSON Schema object of flat values (string, number, integer, boolean, or an enum). The operator is then asked for it in a Slack modal. A capability.tool action needs no form, because the tool's own input schema is the form, minus whatever arguments already answers.

An agent key hands the work over as that agent. An org-wide key can name one with agentId. Send Idempotency-Key, and a retry returns the same thread with 200 instead of announcing it again.

API key management

Shell
# Create a key. The full key is returned once.
curl -X POST -H "Authorization: Bearer $ADMIN_KEY" -H "X-Org-Id: my-org" \
  -H "Content-Type: application/json" \
  -d '{"name": "Production Agent", "scopes": ["*"]}' \
  https://<your-host>/api/v1/keys

# List the keys for your organization
curl -H "Authorization: Bearer $ADMIN_KEY" -H "X-Org-Id: my-org" \
  https://<your-host>/api/v1/keys

# Rotate a key. The key id stays, the old secret stops working at once.
curl -X POST -H "Authorization: Bearer $ADMIN_KEY" -H "X-Org-Id: my-org" \
  https://<your-host>/api/v1/keys/<key-id>/rotate

# Revoke a key now
curl -X POST -H "Authorization: Bearer $ADMIN_KEY" -H "X-Org-Id: my-org" \
  https://<your-host>/api/v1/keys/<key-id>/revoke

Keys are sk_live_<random> for production and sk_test_<random> for sandbox. Only a hash is stored, so the full key is shown once, when you create it. A key manages only its own organization: another organization's key id or org id answers 404. An org-wide key needs keys:manage to manage keys. It may create or rotate only keys whose scopes it already holds; omitting scopes requests *, so a restricted key must specify its delegated scopes explicitly. The platform admin key can issue *.

Developer API resource permissions use <resource>:read for GET and <resource>:write for mutations; write also allows reads. Resources are config, agents (including versions and storage), tools, lines (phone and line provisioning routes), secrets, alerts, usage, people, outcomes, and handoffs. /sync always requires agents:write, tools:write, config:write, and alerts:write, because it updates configuration and reconciles alerts even when omitted. Supplying secrets or capabilities (including an empty replacement list) also requires secrets:write or capabilities:write, respectively. Audit, deployments, and trace exporters retain their dedicated scopes. Default * keys continue to access these routes. An org key cannot edit organization metadata; platform administration owns SSO eligibility. Restoring an agent version also requires write scopes for the config, tools, alerts, capabilities, lines, skills, scripts, and MCP definitions it can change.

Traces and logs

Every turn is recorded as a trace with nested spans. Keiki keeps them for 14 days by default.

Shell
# List recent traces
curl -H "Authorization: Bearer $API_KEY" "https://<your-host>/api/v1/traces?limit=20"

# Get one trace with all of its spans
curl -H "Authorization: Bearer $API_KEY" \
  https://<your-host>/api/v1/traces/<trace-id>

Each trace carries the model, tokens in and out, steps and duration, plus a span for every model call, tool execution and memory retrieval, with inputs, outputs, errors and metadata. A failed turn on the dashboard Logs page can be retried, replaying its original messages through the same conversation or re-running its loop. Retry is refused once the conversation has moved on or another turn is in progress. Retry is also available on failed messages in Conversations.

What Keiki runs for you

You supply the config. Keiki runs the rest:

ServiceWhat it does
MemoryPer-user facts, recall and categorisation
BrowserCloud browser tasks, screenshots and handoff to a person
ScrapePages to Markdown or HTML, site crawling, and brand assets by domain
SandboxPersistent code execution per user
MediaTranscribe audio, describe images, extract documents
LearningQuiet fact extraction after each turn
SteeringGather rapid messages, and interrupt mid-turn
EscalationQueue the conversation in the dashboard Handoffs queue; configured webhook, Slack or email routes add notifications
TransportOutbound delivery and delivery receipts
TracingPer-turn traces with spans, kept for 14 days
API keysCreate, rotate and revoke scoped keys

Versions and restore

Every agent has a version history. Each time its config is written, through the dashboard or through a kit.sync() from your SDK, Keiki records an immutable snapshot of the whole config. Secret values are never versioned, only the fact that they are present.

Open an agent and go to Versions to see its history and restore an earlier version. Restoring applies that config and records a new version, so history only moves forward. The same history is available under /api/v1/developer/agents/:agentId/versions.

An SDK-managed agent can also be restored, but the SDK owns its live config and overwrites it on the next kit.sync().