Skip to documentation
Documentation

Example: a billing agent per organization

A worked example of a Slack billing assistant on Keiki — one deployment request per organization, two webhook tools, an identity webhook, and your own dashboard for approvals.

This is the migration guide made concrete. The company is a billing platform — call it Acme Billing — whose customers run Slack. Each customer gets an agent that can read their billing data and, with approval from someone allowed to approve, change a customer's plan. Nothing here is Acme-specific beyond the names: swap the tool names and scopes for your own.

What Acme writes: two HTTPS endpoints for the tools, one for identity, and (optionally) an actions page in its dashboard. What Keiki does: everything between the Slack message and the call to Acme's endpoint — who is speaking, whether they may, the preview, the card, the approval, the drift check, the replay, the audit trail.

1. The deployment request

One call per organization, from Acme's backend, when the organization installs the Slack app. Re-running it with the same external_ref updates the agent in place.

JSON
POST /api/v1/developer/deployments
Authorization: Bearer <org-wide API key>

{
  "external_ref": "org_acme_customer_42",
  "agent": { "template": "leaf", "name": "Acme billing" },
  "channels": [
    { "type": "slack", "bot_token": "xoxb-…", "signing_secret": "…" }
  ],
  "identity": {
    "webhook_url": "https://api.acme.com/keiki/identity",
    "secret": "<identity signing secret>"
  },
  "actions": {
    "view_url": "https://app.acme.com/org_acme_customer_42/agent/actions/{actionId}",
    "webhook_url": "https://api.acme.com/keiki/actions",
    "secret": "<actions signing secret>"
  },
  "tools": [
    {
      "name": "preview_plan_change",
      "description": "Show what changing a customer's plan would do: proration, credit due now, next invoice. Run this before update_customer_plan, with the same arguments.",
      "executionType": "webhook",
      "webhookUrl": "https://api.acme.com/keiki/tools/preview_plan_change",
      "inputSchema": {
        "type": "object",
        "properties": {
          "customer_id": { "type": "string" },
          "plan": { "type": "string", "description": "Plan id to move the customer to" },
          "remove_plans": { "type": "array", "items": { "type": "string" } },
          "env": { "type": "string", "enum": ["live", "sandbox"] }
        },
        "required": ["customer_id", "plan", "env"]
      },
      "policy": { "requiredScopes": ["billing:read"] }
    },
    {
      "name": "update_customer_plan",
      "description": "Change a customer's plan. Requires approval; call preview_plan_change first with the same arguments.",
      "executionType": "webhook",
      "webhookUrl": "https://api.acme.com/keiki/tools/update_customer_plan",
      "inputSchema": {
        "type": "object",
        "properties": {
          "customer_id": { "type": "string" },
          "plan": { "type": "string" },
          "remove_plans": { "type": "array", "items": { "type": "string" } },
          "env": { "type": "string", "enum": ["live", "sandbox"] },
          "walkthrough": { "type": "string", "description": "One or two sentences for the approver explaining what will change and why." }
        },
        "required": ["customer_id", "plan", "env", "walkthrough"]
      },
      "policy": {
        "approval": "ask",
        "requiredScopes": ["billing:write"],
        "previewTool": "preview_plan_change",
        "freshnessFields": ["plan", "credit_due_now", "next_invoice.total", "next_invoice.date"],
        "descriptionArg": "walkthrough"
      }
    }
  ]
}

Reading the write tool's policy line by line:

approval: "ask"The call never runs from the model's turn. It becomes a pending action with a card; the stored arguments run when someone approves.
requiredScopesChecked against the scopes the identity webhook returned for the speaker before the proposal is even created, and again for whoever approves.
previewToolpreview_plan_change must have run this turn with the same canonical arguments (walkthrough excluded) or the proposal is refused. Its output is what the card shows.
freshnessFieldsDot paths into the preview output. At approval Keiki re-runs the preview and compares only these fields; anything else may change freely.
descriptionArgThe model's explanation for the human. Shown on the card, kept in the record, excluded from the argument hash so wording never counts as drift.

The read tool has no approval, so it runs inline — but it still carries billing:read, so a stranger in a Slack Connect channel gets a refusal, not a customer's invoice.

The leaf template's prompt already teaches the agent this sequence — preview with exact arguments, propose, say one sentence, stop; treat a correction as a new proposal; never say a pending change happened. You add organization rules on top, you do not re-teach the protocol.

2. The tool endpoints

Keiki calls each webhook tool with a POST:

JSON
POST https://api.acme.com/keiki/tools/preview_plan_change
Content-Type: application/json
X-Orchid-Api-Key: <the API key the deployment was created with>
X-Orchid-Trace-Id: <trace id>

{
  "input": { "customer_id": "cus_8ah2", "plan": "enterprise", "remove_plans": ["pro"], "env": "live" },
  "context": { "phone": "<conversation identity>", "channel": "slack" }
}

Authenticate the request by checking X-Orchid-Api-Key against the key you deployed with. When someone on your team steers the conversation from the dashboard, the turn runs for real but nothing reaches the contact; the call then carries X-Orchid-Internal: true and context.internal: true, with context.messages holding what the team member typed, since that message never crossed the channel. Check context.internal before treating the call as a reply to a message your own systems saw arrive. The endpoint does not receive the speaker: by the time it is called, Keiki has already established that a verified principal with the required scope asked for (or approved) the call. If you need the actor for your own audit log, take it from the action lifecycle webhook in §4, which carries the full action record.

Return JSON. The response body becomes the tool result the model sees and, for the preview tool, the card body. Shape the preview as a flat object with scalar fields — Keiki renders them as one line each, arrays of scalars as a comma list, and one level of nested object or array of objects as an indented block — and name the fields you want protected in freshnessFields:

JSON
{
  "customer": "Globex Corp (cus_8ah2)",
  "plan": "Enterprise — $2,400 / month",
  "removing": ["Pro"],
  "credit_due_now": "$1,133.33",
  "next_invoice": { "total": "$2,400.00", "date": "2026-10-01" },
  "environment": "live"
}

Which renders on the card as:

plaintext
*Customer:* Globex Corp (cus_8ah2)
*Plan:* Enterprise — $2,400 / month
*Removing:* Pro
*Credit due now:* $1,133.33
*Next invoice:*
  total: $2,400.00
  date: 2026-10-01
*Environment:* live

The write endpoint receives the identical input (plus walkthrough) when approved, and returns whatever the model should narrate — { "subscription_id": "sub_…", "status": "active" }. A non-2xx response marks the action failed with the body as the reason.

3. The identity webhook

Acme maps a Slack user to an Acme user and their scopes. The request is signed; the response is cached per sender for ttlSeconds. Details and signing are on the identity page; the Acme handler is a lookup:

JSON
POST https://api.acme.com/keiki/identity
X-Keiki-Timestamp: 1789600000
X-Keiki-Signature: sha256=…

{
  "agent": { "id": "agt_…", "externalRef": "org_acme_customer_42" },
  "sender": { "channel": "slack", "teamId": "T_GLOBEX", "userId": "U_MAYA", "email": "maya@globex.com", "displayName": "Maya Chen" },
  "conversation": { "id": "C_BILLING:1789599000.000100" }
}
JSON
{ "userId": "usr_9f2", "role": "admin", "scopes": ["billing:read", "billing:write"], "ttlSeconds": 900 }

externalRef is the organization you deployed for, so the lookup is "is this Slack user a member of org 42, and what is their role there" — the same check your dashboard makes. Map roles to scopes in one place; the strings only have to match what the tool policies name. An unknown person is { "userId": null }: they can still ask questions, and both tools refuse them. When Acme revokes someone, call DELETE /api/v1/developer/deployments/org_acme_customer_42/identity/cache?channel=slack&team_id=T_GLOBEX&user_id=U_MAYA so the next message re-resolves.

4. Your dashboard

Keiki renders the card and the buttons; the human-facing list of actions lives in Acme's own product. Two pieces make that work, both set in actions above:

  • webhook_url receives a signed action.<status> event on every transition (pending, executing, completed, failed, declined, superseded) with the full public action record: tool, arguments, preview, walkthrough, who proposed, who decided. Store these and the dashboard never polls; they are also the audit trail your own log used to be.
  • view_url puts a "View in dashboard" link on every card, pointing at Acme's page for that action ({actionId}, {groupId}, {conversationId} and {toolName} are substituted). The page renders what the webhook stored.

Decisions are made on the Slack card, in words in the thread, or by an owner or admin in the Keiki webapp; a developer-API decide endpoint for your own dashboard is not available yet, so the page is a view, not a second approval surface. Neither setting is required: without actions, the card has no link and Keiki's own Actions list is the only view.

5. What a conversation looks like

  1. Maya, in #billing: "@Acme move Globex to Enterprise and drop Pro, live."
  2. Identity webhook: Maya is usr_9f2, admin, billing:read + billing:write. The prompt's # Speaker says so.
  3. The agent calls preview_plan_change(customer_id, plan, remove_plans, env). It runs inline; the model sees the proration.
  4. The agent calls update_customer_plan with the same arguments plus a walkthrough. The dispatcher sees approval: "ask", confirms the matching preview, persists the action, posts the card with the preview body, the walkthrough, Approve / Decline and "View in dashboard", and returns approval_requested. The agent says "Proposed — waiting for approval" and stops.
  5. Someone clicks Approve. Keiki re-resolves the clicker (scope check: billing:write), re-runs preview_plan_change, diffs plan, credit_due_now, next_invoice.total, next_invoice.date. Unchanged → the stored arguments are sent to update_customer_plan; the card flips to "Approved by Maya — done". Changed → the card is redrawn as a new proposal showing the fresh numbers, asking again.
  6. Instead of approving, Maya replies "actually keep Pro". The agent previews and proposes again; the first card is marked superseded. Two proposals in one turn would share a group card with Approve all.
  7. Acme's dashboard shows the whole thing from the lifecycle webhook, and Maya's next message finds [completed] update_customer_plan … → sub_… in # Actions, so the agent reports what actually happened.

What you did not build

A permission check, an approval table, a Slack card, a preview ledger, a drift check, a replay path, a sequential-message queue, a stop command, a speaker resolver, or a per-organization deployment loop. Two tool endpoints, one identity lookup, one deployment call, and optionally a page in your dashboard.