# Cloud-Served Agents

> Part of the @orchid/agents docs. For running the agent (or the whole platform) on your own infrastructure, see Self-Hosting Agents.

Cloud-served agents are **platform-hosted**: you write a declarative config — system prompt, tools, and feature toggles — and call `kit.sync()`. The Orchid platform runs the entire agentic loop for you and handles transport, billing, memory, browser automation, code execution, and more, across every channel your agent speaks on (SMS, Telegram, Slack, email, WhatsApp). You don't run a server.

```ts
await kit.sync() // push config + tools + plugins to the platform; the platform runs everything
```

If instead you want to run the agentic loop yourself, use `kit.serve()` — see Self-Hosting Agents.

## Quick Start

```bash
# Install the core SDK and a storage adapter
bun add @orchid/agents @orchid/agents-storage-postgres
```

Create your agent:

```ts
// src/index.ts
import { Effect } from 'effect'
import { AgentKit, defineTool } from '@orchid/agents'
import { PostgresStorageLive } from '@orchid/agents-storage-postgres'
import { Schema } from 'effect'

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

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

  storage: PostgresStorageLive({
    connectionString: process.env.DATABASE_URL!,
  }),

  agent: {
    model: 'google/gemini-3.5-flash',
    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: [],
  },
})

// Cloud agent — syncs config + tools + plugins to the platform, which runs everything
await kit.sync()
```

Sync it:

```bash
bun run src/index.ts
# Config synced to platform. The platform now runs your agent.
```

## Channels

The runtime is channel-agnostic: the same agent config serves SMS, Telegram, Slack, email and WhatsApp. Channels are provisioned platform-side — one gateway row per identity (phone line, bot token, inbox) created through the dashboard — while the agent config stays channel-free apart from `resolveNumber`, which picks the outbound SMS line.

The SDK types the same shape via the `channels` field (`ChannelConfig`), but `kit.sync()` does not forward it yet, so it is declarative-only for now:

```ts
const kit = create({
  // …
  resolveNumber: (ctx) => Effect.succeed(process.env.SMS_LINE!),
  channels: [
    { type: 'sms', resolveNumber: (ctx) => Effect.succeed(process.env.SMS_LINE!) },
    { type: 'telegram', botToken: process.env.TG_BOT_TOKEN! },
  ],
})
```

`ChannelConfig` is a union of the channel configs the SDK knows about, and `ChannelType` is an open string union (`'sms' | 'telegram' | (string & {})`) so platform-side channels can be added without an SDK release. On the platform, a channel is an inbound webhook plus a `ChannelAdapter` registered through `registerChannelAdapter` in `packages/platform/src/services/channel-runtime.ts`; adapters ship in `packages/platform/src/channels/` (`linq.ts` for SMS/RCS/iMessage, `telegram.ts`, `slack.ts`, `agentmail.ts`, `photon.ts`).

Because tools, plugins, memory, steering and billing all sit above the channel layer, an agent gains a channel without any change to its agentic config.

### Telegram in group chats

In a Telegram group or supergroup the bot answers only when addressed — an @-mention of its handle, a reply to one of its own messages, a slash command (`/cmd`, or `/cmd@thisbot`; commands targeted at another bot are ignored), or the bot's display name leading the message ("roomie, whose turn is it"). Everything else is read silently into a rolling observation buffer (in-memory, capped at 50 messages per chat) and prepended as marked context the next time the bot is addressed, so staying quiet does not mean losing the thread. Like steering state, the buffer is per-process: a restart drops unanswered context, and it assumes the single-replica deployment documented in self-hosting — making it durable is a known follow-up. Group messages reach the model labeled with the sender's name, and the whole group shares one conversation identity — `tg:chat:{chat_id}` — so members share history and memory. Private chats are unchanged: every message is answered under `tg:{user_id}`.

The SDK's `telegram()` plugin exposes the same behavior with configuration (`TelegramPluginOptions`):

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

Hosted agents get the same behavior with the same defaults, configured per identity via `PATCH /api/webapp/identities/:id/group-chat` (stored on the gateway's `metadata.group_chat`):

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

`mentionAliases` defaults to the bot's display name (so "roomie, whose turn is it" addresses a bot named Roomie); set it to `[]` to require a real @-mention, or list your own aliases (`null` restores the default). `replyMode: "always"` restores answer-everything; `identity: "user"` restores per-sender conversations — with the caveat that a member's group messages and DMs then share one identity, so replies go to whichever chat they wrote in last.

> Upgrade note: mention-gating is the default on both paths. A group deployment that previously answered every message needs `groupReplyMode: 'always'` (SDK) or `{ "replyMode": "always" }` via the PATCH endpoint above (hosted) to keep that behavior, and existing group conversations move to the shared `tg:chat:{chat_id}` identity, which starts a fresh history. The channel-neutral decision logic (gate, buffer, identity, speaker labels) is importable from `@orchid/agents-plugins/groupchat`, and the Telegram adapter from `@orchid/agents-plugins/telegram`, for building the same behavior into other channel adapters.

## Naming: `AgentKit` is the primary API

Use `AgentKit` and its related types for new code. The legacy SMS-prefixed symbols remain exported as deprecated aliases for compatibility with existing consumers; table, API, and environment names are unchanged.

| Primary API | Deprecated alias |
| --- | --- |
| `AgentKit` / `AgentKit.create()` / `create()` | `SmsKit` / `SmsKit.create()` |
| `AgentKitConfig` | `SmsKitConfig` |
| `AgentKitInstance` | `SmsKitInstance` |
| `AgentKitPlugin` | `SmsKitPlugin` |
| `sms_turns` billing feature | `agent_turns` (opt in per deployment with `TURN_FEATURE_ID=agent_turns` once the feature exists in the Autumn config; `sms_turns` stays the default) |

## Adding Custom Tools

```ts
import { defineTool } from '@orchid/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, ctx) =>
    Effect.tryPromise(async () => {
      const res = await fetch(`https://wttr.in/${input.city}?format=j1`)
      const data = await res.json()
      return { temp: data.current_condition[0].temp_C, city: input.city }
    }),
})

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* () {
      // Use platform browser to scrape flight data
      const result = yield* ctx.platform.browse(
        `Search flights from ${input.from} to ${input.to} on ${input.date}`
      )
      return { flights: result.content }
    }),
})

// Pass tools to your agent config
const kit = AgentKit.create({
  // ...
  agent: {
    // ...
    tools: [checkWeather, searchFlights],
  },
})
```

## Managed Platform Features

Toggle features on via config — the platform runs all cognitive infrastructure.

```ts
const kit = AgentKit.create({
  // ...
  agent: {
    model: 'google/gemini-3.5-flash',
    maxSteps: 10,
    historyLimit: 40,
    systemPrompt: ({ user, now, context }) => `...`,
    tools: [checkWeather],

    // --- Managed features (just toggle on) ---

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

    // Cloud browser with human-in-the-loop handoff
    browser: {
      enabled: true,
      handoff: true,
      screenshotDelivery: true,
    },

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

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

    // Cross-step state within a turn
    blackboard: true,

    // Automatic cache breakpoints for cheaper inference
    promptCache: true,

    // Human escalation routing
    escalation: {
      enabled: true,
      requireConfirmation: true,
      routes: {
        webhook: 'https://my-app.com/api/escalations',
        slack: { channel: '#support' },
        email: 'support@mycompany.com',
      },
    },

    // Rapid-fire message debouncing + mid-turn interrupt
    steering: {
      debounceWindow: '2 seconds',
      midTurnStrategy: 'interrupt',
      maxBatch: 5,
    },

    // Auto-process media before it reaches the agent
    media: {
      audio: { action: 'transcribe' },
      image: { action: 'describe' },
      video: { action: 'describe' },
      document: { action: 'extract' },
      fallback: '[Unreadable attachment]',
    },
  },

  // Lifecycle event 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 primary way to extend AgentKit with reusable capabilities. Each plugin is self-contained — it owns its config and contributes tools, hooks, routes, and layers to the runtime without touching the core agent config.

Inspired by [Better Auth's plugin system](https://www.better-auth.com/docs/concepts/plugins): composable, isolated, and declarative.

```bash
bun add @orchid/agents-plugins
```

### Using Plugins

Add plugins to the `plugins` array in your config. This works for both **cloud agents** (platform-hosted, `kit.sync()`) and **local agents** (self-hosted, `kit.serve()` — see Self-Hosting Agents):

```ts
import { AgentKit } from '@orchid/agents'
import { memory, browser, steering, media, observability, alerts } from '@orchid/agents-plugins'

const kit = AgentKit.create({
  apiKey: process.env.ORCHID_API_KEY!,
  name: 'My Agent',
  storage: PostgresStorageLive({ connectionString: '...' }),

  plugins: [
    memory({ provider: 'supermemory', 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: 'google/gemini-3.5-flash',
    maxSteps: 10,
    historyLimit: 40,
    systemPrompt: ({ user, now }) => `...`,
    tools: [myCustomTool],  // only your custom tools here
  },
})

// Cloud agent — syncs config + plugins to platform, platform runs everything
await kit.sync()
```

### Built-in Plugins

| Plugin | What it does |
|--------|--------------|
| `memory(opts)` | Per-user memory: auto-retrieves facts before each turn, extracts new facts after |
| `browser(opts)` | Cloud browser with human-in-the-loop handoff and screenshot delivery |
| `sandbox(opts)` | Persistent code execution sandbox |
| `executor(opts)` | Background job runner with concurrency limits |
| `steering(opts)` | Rapid-fire message debouncing + mid-turn strategy (interrupt/append/queue) |
| `media(opts)` | Pre-processes inbound attachments (transcribe audio, describe images, extract docs) |
| `escalation(opts)` | Human escalation routing (webhook, Slack, email) — per agent; a key-level `PATCH /api/v1/config` applies it to every agent on the key |
| `observability(opts)` | Exports traces to Braintrust, Axiom, OpenTelemetry, or custom backends |
| `alerts(opts)` | Scheduled/triggered alert broadcasts with dedup |
| `events(handlers)` | Lifecycle event handlers (onInbound, onOutbound, onNewUser, onOptOut) |
| `mcp(opts)` | Executor.sh–backed custom tools — let users add MCP servers & OpenAPI specs (requires `sandbox()`) |

### Creating a Custom Plugin

A plugin is an object satisfying the `AgentKitPlugin` interface. Use `definePlugin()` for type safety:

```ts
import { Effect } from 'effect'
import { definePlugin } from '@orchid/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 (Executor.sh — MCP servers & OpenAPI specs)

Let your agent — and the people talking to it — connect custom tools at runtime.
The `mcp()` plugin runs the [Executor.sh](https://github.com/RhysSullivan/executor)
daemon inside the agent's sandbox and exposes tools to:

- `mcp_add_source` — connect a remote **MCP server** by URL or a REST API by its **OpenAPI (Swagger) spec**
- `mcp_execute` — run JavaScript against the connected sources' tools (`tools.search({ query })` to discover, then call by path)

Sources with no auth connect automatically; sources that need an API key or OAuth
return a link the user opens once to authorize, after which their tools become
available (via `mcp_execute`) in the conversation.

**Requires the Sandbox capability.** The daemon needs a sandbox to run in, so
`mcp()` must be composed with `sandbox()` — it throws at boot otherwise.

```ts
import { AgentKit } from '@orchid/agents'
import { PostgresStorage } from '@orchid/agents-storage-postgres'
import { sandbox, mcp } from '@orchid/agents-plugins'

const kit = AgentKit.create({
  apiKey: process.env.ORCHID_API_KEY!,
  name: 'MCP Agent',
  resolveNumber: (ctx) => Effect.succeed(process.env.SMS_LINE!),
  storage: PostgresStorage({ connectionString: process.env.DATABASE_URL! }),
  plugins: [
    // Prerequisite: the Sandbox capability the daemon runs in.
    sandbox({ persistent: true }),
    mcp({
      maxServers: 5,
      userCanAdd: true,
      // Optional MCP servers pre-connected for every conversation.
      servers: [{ url: 'https://my-internal-mcp.example.com/mcp' }],
    }),
  ],
  agent: {
    model: 'google/gemini-3.5-flash',
    maxSteps: 10,
    historyLimit: 40,
    systemPrompt: ({ now }) => `You are an assistant with MCP tool access. Time: ${now.toISOString()}`,
    tools: [],
  },
})

await kit.sync()
```

### Plugin Interface

```ts
type AgentKitPlugin = {
  id: string

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

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

  // HTTP routes (e.g. webhooks, 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/response interceptors
  onRequest?: (req: Request) => Effect<Request | Response | void>
  onResponse?: (res: Response) => Effect<Response>

  // Effect service layers
  layers?: Layer[]

  // Augment platform sync payload
  onSync?: (payload: SyncPayload) => SyncPayload

  // Plugin-owned DB migrations (additive only, never dropped)
  migrations?: PluginMigration[]

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

### Backwards Compatibility

The `plugins` field is optional. Existing configs that specify features directly on `agent` (e.g. `memory: { enabled: true }`) still work unchanged — plugins are additive.

## Observability

Export turn traces to your preferred observability platform. The platform always records traces internally (14-day retention) — exporters send copies to external services.

### Braintrust (AI-specific tracing + evals)

```ts
import { braintrustExporter } from '@orchid/agents'

const kit = AgentKit.create({
  // ...
  agent: {
    // ...
    observability: {
      enabled: true,
      exporters: [
        braintrustExporter({
          apiKey: process.env.BRAINTRUST_API_KEY!,
          projectName: 'my-sms-agent',
        }),
      ],
    },
  },
})
```

### Axiom (structured logs)

```ts
import { axiomExporter } from '@orchid/agents'

const kit = AgentKit.create({
  // ...
  agent: {
    // ...
    observability: {
      enabled: true,
      exporters: [
        axiomExporter({
          apiToken: process.env.AXIOM_TOKEN!,
          dataset: 'sms-agent-traces',
        }),
      ],
    },
  },
})
```

### OpenTelemetry (OTLP — Grafana Tempo, Jaeger, Honeycomb, Datadog, etc.)

```ts
import { otelExporter } from '@orchid/agents'

const kit = AgentKit.create({
  // ...
  agent: {
    // ...
    observability: {
      enabled: true,
      exporters: [
        otelExporter({
          endpoint: process.env.OTEL_ENDPOINT!, // e.g. https://tempo.myinfra.com
          headers: { 'X-Scope-OrgID': 'my-org' },
          serviceName: 'my-sms-agent',
        }),
      ],
    },
  },
})
```

### Multiple Exporters + Sampling

```ts
observability: {
  enabled: true,
  exporters: [braintrustExporter({...}), axiomExporter({...})],
  sampleRate: 0.5,        // Export 50% of traces (platform keeps 100%)
  redactInputs: true,     // Strip PII from span inputs before export
  redactOutputs: false,   // Keep outputs (useful for debugging)
}
```

### Custom Exporter

```ts
import { customExporter } from '@orchid/agents'

const myExporter = customExporter({
  name: 'my-backend',
  exportFn: async (trace) => {
    await fetch('https://my-observability.com/ingest', {
      method: 'POST',
      body: JSON.stringify(trace),
    })
  },
})
```

## Storage Adapters

Your agent config declares a storage adapter for message history, users, and metering.

### Postgres (recommended)

```bash
bun add @orchid/agents-storage-postgres
```

```ts
import { PostgresStorageLive } from '@orchid/agents-storage-postgres'

const storage = PostgresStorageLive({
  connectionString: process.env.DATABASE_URL!,
  schema: 'public',     // optional, defaults to 'public'
  autoMigrate: true,    // optional, auto-creates tables on first run
})
```

### MongoDB

```bash
bun add @orchid/agents-storage-mongodb
```

```ts
import { MongoStorageLive } from '@orchid/agents-storage-mongodb'

const storage = MongoStorageLive({
  uri: process.env.MONGO_URI!,
  database: 'my-sms-agent',
})
```

### Cloudflare (D1 + KV + R2)

```bash
bun add @orchid/agents-storage-cloudflare
```

```ts
import { CloudflareStorage } from '@orchid/agents-storage-cloudflare'

const storage = CloudflareStorage({
  d1: env.DB,                  // required — every relational table lives in D1
  kv: env.CACHE,               // optional — read-through cache for lines and user metadata
  r2: env.BLOBS,               // optional — message bodies over the inline limit
  tablePrefix: 'tenant_',      // optional, defaults to no prefix
  autoMigrate: true,           // optional, creates tables and indexes on first run
  kvTtlSeconds: 300,           // optional, TTL of cache entries
  inlineContentLimit: 4096,    // optional, characters kept inline in D1
})
```

`CloudflareStorage()` returns a `StorageBundle`, so plugins that declare
`migrations` get a `PluginDb` backed by the same D1 database. Plugin SQL is
written for Postgres, so `$n` placeholders, `TIMESTAMPTZ`, `JSONB`, `now()` and
`::type` casts are translated to SQLite and multi-statement migrations are split
and applied in one D1 batch. Postgres-only functions (`jsonb_set`,
`gen_random_uuid()`, …) have no equivalent and still need SQLite spellings.

Bindings are passed in, not read from the environment, so the adapter runs
anywhere the D1/KV/R2 clients do. `@orchid/agents-storage-cloudflare/testing`
ships in-memory fakes (SQLite-backed D1, map-backed KV and R2) for tests that
should not touch a real account.

### Bring Your Own

Implement the `Storage` service tag with any backend:

```ts
import { Storage, StorageError } from '@orchid/agents'
import { Effect, Layer } from 'effect'

const MyStorageLive: Layer.Layer<Storage, StorageError> = Layer.succeed(Storage, {
  insertMessage: (msg) => Effect.succeed({ id: 'custom-id', created: true }),
  getHistory: (phone, limit) => Effect.succeed([]),
  updateMessageContent: (id, content) => Effect.void,
  getUser: (phone) => Effect.succeed(null),
  upsertUser: (phone, patch) => Effect.void,
  getUserMeta: (phone, key) => Effect.succeed(null),
  setUserMeta: (phone, key, value) => Effect.void,
  listSubscribers: () => Effect.succeed([]),
  setOptOut: (phone, opted) => Effect.void,
  claimDelivery: (claim) => Effect.succeed(true),
  markDelivered: (phone, key, id) => Effect.void,
  getLineAssignment: (phone) => Effect.succeed(null),
  setLineAssignment: (phone, line) => Effect.void,
  incrementUsage: (keyId, event) => Effect.void,
  getUsage: (keyId, period) => Effect.succeed({ turns: 0, messages: 0, tokensIn: 0, tokensOut: 0 }),
})
```

## Dynamic Phone Number Resolution

Assign phone numbers per-user or load-balance across a pool:

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

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

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

## Alerts (Scheduled Messages)

Define alerts that run on a schedule and broadcast to subscribers:

```ts
import { defineAlert } from '@orchid/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],
  },
})
```

## Managing Your Agent (Developer REST API)

Developers manage their cloud agent via REST (authenticated with their API key):

```bash
# Get agent config
curl -H "Authorization: Bearer $API_KEY" https://platform.orchid.dev/api/v1/config

# Update model and system prompt
curl -X PATCH -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "anthropic/claude-haiku-4-5", "system_prompt": "You are..."}' \
  https://platform.orchid.dev/api/v1/config

# Upload a custom tool (webhook-based)
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://platform.orchid.dev/api/v1/tools

# Upload a custom tool (sandboxed code)
curl -X POST -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "calculate",
    "description": "Evaluate a math expression",
    "inputSchema": {"type": "object", "properties": {"expr": {"type": "string"}}, "required": ["expr"]},
    "executionType": "code",
    "code": "return { result: eval(input.expr) }"
  }' \
  https://platform.orchid.dev/api/v1/tools

# Acquire a phone number
curl -X POST -H "Authorization: Bearer $API_KEY" \
  -d '{"pool": "us-west", "strategy": "least-active"}' \
  https://platform.orchid.dev/api/v1/phones/acquire

# Hand something to a person (any kind of work — the agent need not have escalated)
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",
        "authorization": {"principals": [{"type": "slack_user", "id": "U123"}]},
        "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://platform.orchid.dev/api/v1/handoffs

# Check usage
curl -H "Authorization: Bearer $API_KEY" https://platform.orchid.dev/api/v1/usage
```

The work is announced in the agent's configured Slack destination as a thread,
with the buttons this request supplied. It requires the agent's
`operatorHandoff` capability; `announced: false` in the response means the
thread exists but no Slack install is configured to show it.

What the work *is* stays with whoever owns it. Send `conversation` instead of
`subject` and it is one of this agent's own conversations, so its thread also
offers take over, reply and hand back. Anything else is named by reference and
gets no conversation controls, no status here, and no resolution here: closing
its thread ends the Slack session and says nothing about the record.

Each action names something this 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 itself reaches through, pressed by a person instead. A
capability the agent has not installed, or a tool of a kind this deployment
cannot run, leaves its button off the card rather than failing under the press;
where two publishers ship the same slug, write `capability` as
`"publisher/slug"`. Either way it is delivered at most once when someone presses
it, with the click recorded. That receipt says the call was delivered; whether
the entry is approved is your system's to say. Once is the default even for an
action with a form: a second press, a second operator, or a second modal finds
the run already taken. Add `"repeatable": true` for a button that is meant to be
pressed again — a note, a nudge — and each submission is delivered on its own.

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

A key issued for one agent 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 opening a second one or announcing it again.

### API Key Management

Create, rotate, and revoke API keys programmatically:

```bash
# Create a new API key (returns the full key 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://platform.orchid.dev/api/v1/keys
# → { "id": "...", "key": "sk_live_AbC...", "keyPrefix": "sk_live_AbC..." }

# List all keys for your org
curl -H "Authorization: Bearer $ADMIN_KEY" -H "X-Org-Id: my-org" \
  https://platform.orchid.dev/api/v1/keys

# Rotate a key (old key valid for 24h grace period)
curl -X POST -H "Authorization: Bearer $ADMIN_KEY" -H "X-Org-Id: my-org" \
  -d '{"gracePeriodHours": 24}' \
  https://platform.orchid.dev/api/v1/keys/<key-id>/rotate

# Revoke immediately
curl -X POST -H "Authorization: Bearer $ADMIN_KEY" -H "X-Org-Id: my-org" \
  https://platform.orchid.dev/api/v1/keys/<key-id>/revoke
```

Key format: `sk_live_<random>` (production) or `sk_test_<random>` (sandbox). Keys are stored as SHA-256 hashes — the full key is only returned at creation time.

### Traces & Logs

Every agent turn is recorded as a trace with nested spans. Default retention: **14 days**.

```bash
# List recent traces
curl -H "Authorization: Bearer $API_KEY" \
  https://platform.orchid.dev/api/v1/traces?limit=20

# Get a specific trace with all spans
curl -H "Authorization: Bearer $API_KEY" \
  https://platform.orchid.dev/api/v1/traces/<trace-id>
```

Each trace contains:
- Model used, tokens in/out, total steps, duration
- Nested spans for each LLM call, tool execution, memory retrieval
- Span-level input/output, errors, metadata

## Architecture

```
┌─────────────────────────────────────────────────────┐
│                  Orchid Platform                      │
│  (Transport, Billing, Memory, Browser, Sandbox...)   │
│  Runs your agent's full agentic loop after kit.sync() │
└──────────────────────┬──────────────────────────────┘
                       │ SMS in/out (Linq), billing, memory, browser, sandbox
                       ▼
                 Your users' phones
```

With a cloud-served agent, you push your config and tools to the platform with `kit.sync()`. The platform loads that config, runs the LLM turn with platform-provided and developer-uploaded tools, and handles SMS delivery, phone number management, and all cognitive infrastructure. You never run a server.

### Platform Services

| Service | Provider | Purpose |
|---------|----------|----------|
| Memory | Supermemory | Per-user fact store, recall, categorization |
| Browser | Orchid browser runner (Browserbase + Stagehand) | Cloud browser tasks, screenshots, and HITL handoff URLs |
| Scrape | Context.dev | Scrape pages to Markdown/HTML, crawl sites, list page images, and pull brand logos/colors by domain |
| Sandbox | Daytona | Persistent code execution per user |
| Media | Gemini | Transcribe audio, describe images, extract docs |
| Learning | Platform + Supermemory | Silent post-turn fact extraction |
| Steering | Platform | Debounce rapid messages, mid-turn interrupt |
| Escalation | Platform | Route to human support (webhook/Slack/email) |
| Transport | Linq | Outbound SMS delivery + DLR tracking |
| Tracing | Platform (+ exporters) | Per-turn traces with spans, 14-day retention |
| API Keys | Platform | Create, rotate, revoke with SHA-256 hashing |

## Config Versioning & Restore

Every agent has a native version history. Each time an agent's configuration is
written — via `kit.sync()` for self-hosted/dev and cloud agents, or through the
dashboard for cloud agents — the platform records an immutable snapshot of the
**full** config (name, model, max steps, history limit, system prompt, feature
flags, line assignment, storage mode, webhook URL, tools, alerts, escalation and
steering settings). Secret *values* are never versioned — only their presence.

Snapshots are stored as Git commits in a [Code.Storage](https://code.storage)
repository (one repo per org, one subtree per agent), so history is append-only
and every prior configuration stays reachable.

### Restore

From the dashboard, open an agent → **Versions** tab to see its history and
restore any prior version. Restoring re-applies that configuration to the live
agent and records a new "Restore …" version — history only moves forward, so
nothing is ever lost. Programmatic access is available under
`/api/v1/developer/agents/:agentId/versions`.

Local (SDK-managed) agents can also be restored, but the SDK owns their live
config and will overwrite it on the next `kit.sync()`.

### Configuration

Versioning is enabled by setting `PIERRE_STORAGE_KEY` (plus `PIERRE_STORAGE_NAME`
and optional `PIERRE_STORAGE_REPO_PREFIX`, default `pro__`). The legacy
`CODE_STORAGE_KEY` / `CODE_STORAGE_ORG` / `CODE_STORAGE_REPO_PREFIX` names are
still honored as a fallback. When unset, all snapshot/restore paths are no-ops
and the platform runs unchanged.

> **Maintainers:** the set of fields captured in each snapshot is defined
> declaratively in `packages/platform/src/services/versioning.ts`
> (`AgentConfigSnapshot` + `SNAPSHOT_FILES` + `collectAgentSnapshot` /
> `applySnapshot`). **When you add a new agent-config field, update that file**
> so the field is versioned and restored — otherwise it will be silently dropped
> from history.
