Skip to content

Provider Layer

This document explains the layer where Anima talks to an underlying provider such as Codex CLI, Claude Code, or Kimi CLI.

It intentionally does not re-explain Slack routing, reminder scheduling, inbox ingestion, or the web app. For the system map, start with Architecture overview.

Mental Model

Anima has one durable primary session per Slack bot. Provider sessions are lower-level execution details under that primary session.

The runtime worker owns Anima inbox item execution:

  1. claim a queued item;
  2. build the current RuntimeContext;
  3. use AgentRuntimeBridge to turn Anima context into provider-facing input;
  4. call the configured provider adapter through AgentRuntime.run;
  5. append same-session follow-up messages through AgentRuntime.appendToActiveRun;
  6. mark items completed or failed;
  7. close provider resources when the worker shuts down.

Provider adapters own only the protocol to the underlying CLI process. They do not receive inbox items, Slack channel/thread/DM objects, or agent state. They do not decide Slack eligibility, queue priority, reaction policy, prompt construction, or whether visible output should be posted. Visible Slack output still has to go through Anima tools from inside the spawned provider process.

The adapter boundary is:

text
Anima context/state -> AgentRuntimeBridge -> provider-facing prompt/env/sinks -> provider adapter -> CLI process

The Provider Contract

The contract lives in server/providers/contract.ts.

ts
export interface AgentRuntime {
  readonly env?: Record<string, string>;
  readonly kind: string;
  close?(): Promise<void>;
  run(input: AgentRuntimeInput): Promise<AgentRuntimeResult>;
  appendToActiveRun(
    input: AgentRuntimeFollowupInput,
  ): Promise<AgentRuntimeFollowupResult>;
}

kind identifies the provider and is also the key used for provider-session storage.

run is required. It starts or resumes provider work for one Anima inbox item and resolves when the provider work is done.

appendToActiveRun is required. It lets the worker send a newly queued same-session item into the active provider context instead of waiting for the active item to finish.

close is optional. It is for provider adapters that keep resources alive across items, such as a persistent child process.

Runtime Input

AgentRuntimeInput contains:

  • itemId: the Anima inbox item id, used as an Anima-side correlation key;
  • cwd: the agent home directory for the child process;
  • env: the complete child environment, already built by Anima;
  • prompt: the text to send into the provider for this item;
  • systemPrompt: optional runtime-profile text for providers that accept a separate system prompt;
  • providerSession: the provider-native session id, if one exists;
  • signal: an abort signal controlled by the worker for stop, idle timeout, and shutdown;
  • onActivity: a heartbeat callback the provider calls when stdout/stderr activity arrives;
  • effects: a sink for recording activities and persisting provider session ids.

The important boundary: AgentRuntimeInput does not contain inbox items, Slack channel/thread/DM objects, or agent state beyond the provider-facing prompt, environment, and effect sink.

The worker uses onActivity to reset the idle watchdog. If the provider produces no activity for the configured idle timeout, the worker aborts the item.

Effects Sink

AgentRuntimeEffects is how provider adapters report provider events back to Anima:

  • recordRuntime: runtime start/completion/failure;
  • recordOutput: raw stdout/stderr chunks;
  • recordAgentText: provider assistant text;
  • recordEvent: provider lifecycle events such as compact or session stats;
  • recordToolStarted / recordToolFailed: provider-side tool activity;
  • persistProviderSession: provider-native session id updates.

The sink is Anima-aware; the adapter is not. AgentRuntimeBridge binds the sink to the current agent id, state dir, session, and runtime kind before calling the adapter.

Runtime Result

AgentRuntimeResult.text is internal runtime output. It is useful for logs and inspection, but Anima does not post it to Slack automatically. The spawned code agent must call anima message send or anima message update for visible Slack side effects.

How the Worker Uses Providers

AgentRuntimeWorker is the only caller of the provider contract in normal service execution.

For a claimed item:

  1. claimNextInboxItem marks the first queued item as running and writes the worker id.
  2. runtimeContextForItemId rebuilds the full runtime context.
  3. AgentRuntimeBridge in server/runtime/runtime-bridge.ts builds provider-facing prompt, env, providerSession, and effects.
  4. The worker starts a parallel follow-up loop while the active item is running.
  5. The worker calls agentRuntime.run(providerInput).
  6. On success, the worker records completion and marks the item completed.
  7. On error or abort, the worker records failure and marks the item failed.
  8. onItemSettled runs after either path; the agent service uses it to remove processing reactions from the active item and any appended follow-up items.

Only one normal item can be running for an agent at a time. Follow-up items are temporarily claimed by the same worker while the active item is still running.

Active-Run Follow-Up Protocol

Active-run follow-up append is Anima's way to preserve the "one teammate, one primary session" behavior while a provider is busy.

When a same-session message arrives during an active item:

  1. ingestion creates a normal queued item for that message;
  2. the active worker loop notices the queued item;
  3. claimNextFollowup claims it for the same worker;
  4. AgentRuntimeBridge builds a provider-facing follow-up input with activeItemId, follow-up itemId, and prompt;
  5. the worker calls agentRuntime.appendToActiveRun;
  6. if accepted, the follow-up item is marked completed immediately and gets a runtime.followup_appended activity;
  7. if rejected, the item is requeued and will execute after the active item.

Accepted follow-up append means the provider adapter has taken responsibility for injecting that message into the active provider context. The follow-up item does not get its own independent provider execution.

This is why reaction cleanup runs for both the active item and accepted follow-up items: the human sees multiple Slack messages being worked on, but the provider sees one active execution context.

Prompt Boundary

The shared prompt helpers live in server/runtime/delivery-prompt.ts and server/runtime/standing-prompt.ts. Provider adapters do not call them directly; server/runtime/runtime-bridge.ts calls them before invoking the adapter.

The Anima runtime profile tells the provider-side agent how Slack side effects work, which anima tools exist, and which environment variables are available. This is platform behavior, not provider-specific behavior.

The runtime profile is delivered through provider-native standing-prompt mechanisms. The per-item prompt contains only the current Slack or reminder event. It may include "Recovery context" when Anima does not have a persistent provider session yet. Recovery context is a safety net, not the product session model.

Environment Boundary

runtimeEnv builds the child process environment. AgentRuntimeBridge calls it and passes the completed env to the adapter.

The important pieces are:

  • ANIMA_AGENT_ID and ANIMA_HOME, so agent-facing CLI tools can locate config and state;
  • configured provider env from the agent config;
  • a PATH that includes Anima's agent-facing CLI.

ANIMA_INBOX_ITEM_ID is deliberately stripped from the long-lived provider environment. Slack-visible tools resolve the audited item at call time from runtime/active-item.json.

Provider code should not read Slack tokens directly. It should call anima message, anima reminder, or anima subscription so the side effect is audited against the current item.

Provider Sessions

Provider session ids are stored on Anima's primary session record by provider kind. AgentRuntimeBridge reads the current provider session and passes it to the adapter as providerSession.

They are used to resume the underlying tool's native context:

  • Codex: the stored id is the Codex thread id;
  • Claude: the stored id is the Claude Code session id.

When a provider emits a new session id, the adapter calls effects.persistProviderSession. The sink updates Anima's primary session record.

Provider sessions are not the Anima product session. If a provider session is compacted, rotated, restarted, or replaced, Anima still has the durable primary session, inbox history, instructions, and activity log.

Codex Adapter

Implementation: server/providers/codex.ts.

Current process model:

  • Anima starts one persistent codex app-server --listen stdio:// process for the runtime worker.
  • The Codex thread id is persisted and resumed on later items.
  • The process stays alive across Anima items until abort or worker shutdown.
  • Anima sends the runtime standing prompt through Codex developerInstructions; each item input contains only the bridge-built delivery prompt.
  • Thread start/resume explicitly sets approvalPolicy: "never", sandbox: "danger-full-access", optional model, and optional config.model_reasoning_effort.

Protocol:

  1. send JSON-RPC initialize;
  2. send thread/start or thread/resume;
  3. persist the returned thread id as the codex-cli provider session;
  4. send turn/start with the bridge-built delivery prompt;
  5. collect item/agentMessage/delta notifications as internal text;
  6. map provider tool notifications to Anima activities;
  7. resolve when turn/completed arrives.

Active-run follow-up:

  • Once turn/start returns a turn id, the adapter exposes that id as ready.
  • appendToActiveRun sends turn/steer with expectedTurnId.
  • If Codex accepts the request, the worker marks the new queued item completed as part of the active item.

Activity mapping:

  • item/started can become tool.call.started;
  • failed command/file/MCP/web-search items can become tool.call.failed;
  • contextCompaction items become runtime.event codex.compact.started / codex.compact.completed / codex.compact.failed;
  • turn/completed usage/model/status data becomes runtime.event codex.session.stats;
  • assistant text deltas are accumulated and returned as internal AgentRuntimeResult.text.

Claude Adapter

Implementation: server/providers/claude.ts.

Current process model:

  • Anima starts one persistent claude process for the runtime worker.
  • It uses stream-json input/output over stdio.
  • It intentionally does not use claude -p.
  • If Anima has a stored Claude session id, startup includes --resume <session_id>.
  • The adapter sets CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 by default; agent config provider.env can override it.
  • The process stays alive across Anima items until abort or worker shutdown.

Command shape:

text
claude
  --output-format stream-json
  --verbose
  --input-format stream-json
  --permission-mode bypassPermissions
  --disallowedTools AskUserQuestion,CronCreate,CronDelete,CronList,ScheduleWakeup,RemoteTrigger,PushNotification
  [--resume <session_id>]
  [--model <model>]
  [--effort <reasoningEffort>]
  --system-prompt-file <runtime prompt file>

Provider Tool Policy

Anima uses provider tools for observability only; Slack side effects, reminders, subscriptions, inbox routing, and scheduling must stay Anima-owned. Claude Code currently receives a small strategic denylist through --disallowedTools:

ToolCurrent CLI presenceStream-json behaviorSide effectDecision
AskUserQuestionClaude Code built-inFails in the non-interactive runtime.Attempts to ask the operator outside Anima.Deny
CronCreate / CronDelete / CronListClaude Code built-insWorks as Claude-native session cron management.Creates or manages recurring scheduled prompts outside Anima inbox/reminder/activity ownership.Deny
ScheduleWakeupClaude Code built-inWorks as Claude-native one-off delayed wake.Creates future wakeups outside Anima reminders and audit.Deny
RemoteTriggerClaude Code built-inNot needed by Anima runtime.Establishes provider-native remote triggers outside Anima routing.Deny
PushNotificationClaude Code built-inNot needed by Anima runtime.Sends provider-native notifications outside Anima-visible messaging.Deny
SlashCommandClaude Code built-inObserve. Some commands are internal and may be valid in stream-json.Can affect Claude session state, but not proven broken in Anima.Allow/observe
File, shell, search, task, todo, notebook, and skill toolsClaude Code built-insRequired for normal agent work.Provider work, surfaced through Anima activity mapping.Allow
Codex CLI toolsCodex app-server protocolNo equivalent user-question/scheduler controls found in the current adapter surface.Tool activity is mapped by Anima.Allow/observe
Kimi CLI toolsKimi wire protocolAnima initializes with supports_question: false and supports_plan_mode: false.Tool activity is mapped by Anima.Allow/observe

The denylist is global for now. Per-agent tool policy should be added only when there is a concrete operator need; the default policy should keep provider-native scheduling and notifications out of the runtime.

Provider run protocol:

  1. ensure the persistent Claude process exists;

  2. mark the Anima item as active;

  3. create a current provider controller;

  4. write one bridge-built delivery prompt as a JSONL user message to Claude stdin:

    json
    {
      "type": "user",
      "message": {
        "role": "user",
        "content": [{ "type": "text", "text": "..." }]
      }
    }
  5. stream Claude stdout through the JSONL activity mapper;

  6. resolve the item on Claude type: "result";

  7. leave the Claude process open for the next Anima item.

Active-run follow-up:

  • appendToActiveRun is accepted only when the requested active item id matches the adapter's current active item.
  • Accepted follow-up input either writes another JSONL user message to the same Claude stdin or queues it behind the adapter's input gate.
  • The input gate closes while Claude is compacting or while provider tool calls have not emitted matching tool_result items.
  • Queued follow-up input is flushed only after compacting is done and outstanding provider tool calls are closed.

Compact and stats:

  • system/status with status: "compacting" becomes runtime.event claude.compact.started.
  • system/compact_boundary becomes runtime.event claude.compact.completed.
  • system/status with compact_result: "failed" becomes runtime.event claude.compact.failed.
  • result usage/model data becomes runtime.event claude.session.stats.

The web app reads the latest claude.session.stats activity to show model, context window, cache-read tokens, cache-create tokens, output tokens, terminal reason, and update time.

Abort behavior:

  • Worker stop, idle timeout, or shutdown aborts the active item's signal.
  • The Claude adapter responds by killing the persistent child process.
  • The next item starts a fresh Claude process and resumes from the stored provider session id when possible.

Why stdout is not buffered:

  • Persistent Claude sessions can run for a long time and produce large JSONL streams.
  • child-process.ts supports bufferOutput: false so stream callbacks still run but stdout/stderr are not accumulated in memory.

Agent Activities

Provider adapters write activities so the user can inspect what happened without reading raw provider logs.

Common runtime activities:

  • runtime.started: provider process/transport began work;
  • runtime.completed: provider work finished normally;
  • runtime.failed: provider work threw or exited unsuccessfully;
  • runtime.aborted: worker aborted the item because of idle_timeout, shutdown, or user_stop;
  • runtime.output: raw stdout/stderr chunks when they are not parsed into richer records;
  • runtime.event: provider lifecycle events such as compact and session stats.

Provider tool activities:

  • tool.call.started: provider-side tool/action started;
  • tool.call.failed: provider-side tool/action failed.

Agent text:

  • agent.text: assistant text observed from provider stdout.

Slack tool activities are separate. When the spawned code agent calls anima message send, that goes through server/slack/messages.ts and records tool.call.started / tool.call.completed / tool.call.failed for the Slack side effect. Provider shell/Bash wrapper rows for first-class Anima CLI tools (anima message read/send/update/react, anima file send) are suppressed so the activity stream shows the semantic Slack tool row once.

Current Boundaries and Tradeoffs

  • Codex and Claude both keep provider continuity through a persisted provider session id and a persistent child process for the lifetime of the worker.
  • Auto-compact is provider-owned. Anima observes compact events and records them; it does not perform compaction itself.
  • Active-run follow-up append is best-effort. If a provider rejects a follow-up, the item is requeued and processed after the active item.
  • An accepted follow-up item is considered absorbed by the active item. It will not have a separate provider result.
  • A Claude item can span more than one provider result boundary when queued follow-up input is flushed at the boundary; Anima waits for the final provider result before completing the active item.
  • Provider sessions are execution-layer state. The durable product session is still Anima's primary session plus inbox/activity history and home instructions.

Adding Another Provider

A new provider should:

  1. implement AgentRuntime;
  2. set kind and optional env;
  3. consume the bridge-provided prompt, cwd, env, providerSession, signal, and effects;
  4. map provider stdout/stderr into effects;
  5. persist provider session ids through effects.persistProviderSession;
  6. implement appendToActiveRun using the provider's real in-flight input protocol;
  7. implement close if it keeps a process or connection alive beyond a single item.

The worker should not need provider-specific changes for a new adapter.