Sprinter Docs

Agent System

DB-managed AI agents with configurable tool groups, scheduled heartbeat execution, external connections, delegation, config versioning, and a unified execution runtime.

Overview

The agent system orchestrates AI agents that operate within the Sprinter Platform. Agents are managed in the database via the agents table and Admin UI. System agents are seeded and backfilled from product.config.ts, while tenant-specific agents are created at runtime.

Agents serve two execution contexts: supervised (user-driven chat, where the agent inherits the user's permissions) and autonomous (heartbeat/scheduled execution, where the agent uses its own role's permissions). This dual-context model lets the same agent definition work safely in both interactive and background scenarios.

The agent system is built on AI SDK v6 (streamText, ToolSet) and supports both internal model providers and external agent connections (OpenClaw, A2A protocol, MCP).

Key Concepts

Agent Definition

Runtime execution uses a normalized AgentDefinition shape produced from DB records:

interface AgentDefinition {
  id: string;
  name: string;
  description: string;
  icon: string;
  systemPrompt: string;
  suggestions?: string[];
  toolGroups?: string[];
  getTools: (permissions?: AppPermission[]) => ToolSet;
}

The platform ships with three default system agents:

AgentIDTool GroupsPurpose
Primary assistantConfigured in product.config.tsentityGeneral intelligence -- search, create, analyze the entity graph
AnalystanalystentityDeep analysis, scoring, prioritization, ROI assessment
Researcherresearcherentity, webResearch, enrichment, web search, finding connections

These agents are stored in the agents table as is_system = true rows. The runtime backfills missing system agents on first resolution so upgraded installs do not depend on a manual seed step.

Agent Record (DB-Managed)

DB-managed agents are rows in the agents table:

  • slug -- Unique identifier within the tenant
  • name / description / icon -- Display metadata
  • system_prompt -- The agent's system prompt
  • model -- Optional model override (e.g., claude-sonnet-4-6, gpt-4o)
  • config -- JSONB column storing AgentConfig
  • enabled -- Whether the agent is available
  • role_id -- FK to roles table, determines permissions for autonomous execution
  • is_system -- Whether the agent is a system agent (cannot be deleted)
  • reports_to -- FK to another agent (org chart hierarchy)
  • connection_id -- FK to agent_connections for external agents
  • external_agent_id -- ID of the agent on the external system

Agents are created and managed via Admin > Agents or the API. The runtime resolves both tenant and system agents directly from the database.

AgentConfig

The configuration stored in the DB config JSONB column:

interface AgentConfig {
  toolGroups?: string[]; // Platform tool bundles to include
  customTools?: string[]; // Individual tool slugs to add
  skills?: string[]; // Skill identifiers
  suggestions?: string[]; // Suggested prompts for the chat UI
  heartbeat?: HeartbeatSchedule; // Scheduled execution config
}

parseAgentConfig() safely parses the raw JSONB into a typed AgentConfig, handling missing or malformed data gracefully.

Tool Groups

Tool groups are named bundles of platform tools:

const TOOL_GROUPS = {
  ENTITY: "entity", // 8 entity CRUD tools + document search
  WEB: "web", // webSearch (Exa API)
  ANALYSIS: "analysis", // Analysis user-facing tools
  ASSESSMENT: "assessment", // Assessment user-facing tools
  PLANNING: "planning", // Planning user-facing tools
  WORKFLOW: "workflow", // getWorkflowStatus, triggerWorkflow, retryNode
  CONTEXT: "context", // addCorrection, addLesson, getUsageStats
  ADMIN: "admin", // updateFieldConfig, updateEntityTypeSchema, updateAgentConfig, manageView + view tools
  MEDIA: "media", // Image/video generation and processing
} as const;

Each group maps to a function that returns the corresponding tools as an AI SDK ToolSet. Including "entity" in toolGroups also automatically includes document search tools.

Baseline tools (auto-included for every agent):

  • getTaskStatus — read-only task status (every agent gets it)

That's the entire baseline. Task mutation tools (manageTasks, retrySession) require declaring "tasks" in config.toolGroups explicitly. This is a deliberate tightening (PR #1175, 2026-05-05): the prior baseline auto-included the full tasks group, which meant every agent — including admin-restricted system agents — implicitly inherited task mutation capabilities. DB-managed agents that depend on manageTasks / retrySession must include "tasks" in toolGroups; the seeded system agents already do, but tenant-authored custom agents that rely on those tools need to be re-saved with the group selected.

Heartbeat Schedule

Agents can be configured for autonomous scheduled execution. As of Phase 7, scheduling is managed via the actions table (a cron action row with agent_slug set), not config.heartbeat.{enabled,schedule}.

interface HeartbeatSchedule {
  /** @deprecated Phase 7: use the cron actions table. setAgentHeartbeatAction() manages the row. */
  enabled?: boolean;
  /** @deprecated Phase 7: use trigger_config.schedule on the cron action row. */
  schedule?: string;
  prompt: string; // System prompt for the heartbeat run
  mode?: "light" | "full"; // Execution depth
  maxDurationSec?: number; // Timeout for the run
  timezone?: string; // Timezone for cron evaluation
  scanActions?: boolean; // Whether to check for assigned actions/session work
  scanGoalsAndTasks?: boolean; // Whether to review goals and tasks
}

Default schedule: every hour (0 * * * *). Default prompt: review open workflows, goals, and tasks.

To enable/disable heartbeat programmatically, call setAgentHeartbeatAction(agentId, { schedule, enabled }) from features/agents/server/heartbeat-action.ts. This upserts the cron action row and sets status = 'active' | 'disabled'.

How It Works

Tool Resolution Pipeline

When an agent needs tools (for chat or heartbeat execution), resolveAgentTools() in features/agents/resolve-tools.ts builds the ToolSet:

  1. Parse config -- parseAgentConfig(config) extracts toolGroups and customTools arrays.
  2. Tool group expansion -- Each tool group maps to a function:
    • entity calls getEntityTools(permissions) + getDocumentTools()
    • web calls getWebTools()
    • workflow calls getWorkflowTools(permissions)
    • context calls getContextTools(permissions)
    • admin calls getAdminTools(permissions) + getViewTools(permissions)
    • media calls getMediaTools(permissions)
  3. User-facing tool mapping -- Tool groups that map to user-facing tool categories (analysis, assessment, planning) pull matching tools from the user-facing tool registry.
  4. Custom tool inclusion -- Individual tool slugs in customTools are pulled from the user-facing tool registry.
  5. Permission filtering -- Each tool function receives the caller's permissions array and excludes tools the caller lacks permission for.

The result is a single ToolSet object that can be passed directly to AI SDK's streamText().

DB-to-Code Bridge

dbAgentToDefinition() converts an AgentRecord into an AgentDefinition plus metadata fields (modelOverride, roleId, connectionId, externalAgentId). This lets chat, heartbeat, extraction, and workflow execution share one runtime shape.

Agent Execution Runtime

executeAgent() in features/agents/runtime.ts is a thin wrapper around AI SDK streamText with standardized cost tracking:

interface AgentRunConfig {
  agent: ResolvedAgent;
  systemPrompt: string;
  messages: ModelMessage[];
  tools: ToolSet;
  maxSteps?: number; // Default: 5
  abortSignal?: AbortSignal;
  onStepFinish?: StepCallback;
  onFinish?: FinishCallback;
  modelOverride?: string;
  source: CostSource; // "chat" | "heartbeat" | "workflow" | "extraction" | "delegation" | "inbox"
  chatId?: string | null;
}

The runtime handles:

  1. Model resolution -- Internal agents use resolveModel(). External agents (with connectionId) use resolveExternalModel() with connection credentials.
  2. Prompt shaping -- Stable context is prepended as system messages, which keeps prompt assembly composable and enables provider-level caching.
  3. Tool normalization -- Tool definitions are sorted deterministically before execution so stable tool schemas can be reused by provider caching.
  4. Execution -- Calls streamText() with the resolved model, prepared prompt messages, tools, and step limit.
  5. Cost tracking -- onFinish records token usage to the cost tracking system (fire-and-forget).
  6. Response collection -- collectAgentResponse() extracts structured parts, plain text, and usage from the stream.

The runtime also exposes toolChoice and prepareStep, which allows callers to force or narrow tool use when a loop needs tighter control.

MCP Gateway

When a chat run is prepared, the platform fetches the tenant's active MCP connections and injects two gateway tools into the agent's ToolSet:

ToolPurpose
listMcpToolsDiscover tools from one or all connected MCP servers
callMcpToolExecute a named tool on a specific server

How it flows:

  1. app/api/chat/route.ts calls getMcpServerConfigs(tenantId) in parallel with the prompt context load (no sequential waterfall).
  2. The resulting McpServerConfig[] is passed to buildInternalAgentTools() via the mcpConfigs param.
  3. buildInternalAgentTools() calls createMcpGatewayTools(configs) from features/mcp/gateway-tools.ts, which returns {} when configs is empty and the 2-tool gateway ToolSet when at least one MCP connection is active.
  4. The 2 tools are merged into the agent's ToolSet alongside internal tools, delegation, and skill loading.
  5. At inference time the agent calls listMcpTools to discover available tools on a server, then callMcpTool to execute them.

Context cost is fixed at 2 tools regardless of how many tools the connected MCP servers expose. Tool schemas are loaded lazily only when the agent calls listMcpTools, not at ToolSet assembly time. This keeps the prompt prefix stable across requests, which is the invariant Anthropic prompt caching depends on.

Config resolution and caching:

getMcpServerConfigs(tenantId) in features/mcp/resolve-configs.ts queries agent_connections for active MCP rows, converts each connection's encrypted credentials through resolveConnectionAuthHeaders(), and returns McpServerConfig[]. Results are cached cross-request via unstable_cache with tag mcp-configs-{tenantId}. The tag is invalidated by connection-actions.ts on every MCP connection create, update, or delete.

Because agent_connections has no uniqueness constraint on (tenant_id, name), the resolver de-duplicates names with numeric suffixes (slack, slack-2, slack-3) so every routing key is unique. The query orders by (created_at, id) to keep the list deterministic — the server names are embedded in listMcpTools's description prefix, so they must not change between requests.

Autonomous paths (heartbeat, workflows, extraction) are not affected. They pass mcpConfigs: [], which causes createMcpGatewayTools to return {} and adds zero tools. These paths can opt in later by passing configs explicitly.

Supervised vs. Autonomous Execution

The permission model differs based on execution context:

ContextExamplePermissions Source
SupervisedUser chatting with an agentgetUserPermissions() -- agent inherits user's permissions
AutonomousHeartbeat cron job, extraction triggergetPermissionsForRole(agent.role_id) -- agent uses its own role

In both cases, tools the caller cannot use are excluded from the ToolSet before the agent sees them.

Human-in-the-Loop: Tool Approval Gates

Tools can declare permissionPolicy: "always_ask" to require explicit user approval per invocation. When an agent tries to call a gated tool mid-execution — whether in a chat session, a heartbeat run, or a cron-triggered automation — the session pauses in awaiting_tool and an approval task is created that surfaces in the user's "waiting on you" inbox.

Declaring a gated tool

  • Custom tools registered via ToolDefinition set permissionPolicy: "always_ask" directly on the definition
  • Admin tools (inline AI SDK Tool objects) are listed in ADMIN_ALWAYS_ASK_TOOLS in features/tools/admin/index.ts. buildInternalAgentTools() attaches permissionPolicy at runtime (AI SDK's Tool type has strict excess property checking, so the field cannot be declared inline on the object literal)

The canary for this mechanism is deleteEntityType — the first destructive admin tool to require explicit approval.

The pause-resume flow

  1. LocalSprinterAgent scans tools for permissionPolicy: "always_ask". If any are present, it takes a "slow path" that strips execute from those tools and calls executeAgentWithMessages() instead of executeAgentSync()
  2. Because the gated tools have no execute function, AI SDK v6 treats them as "client-side tools" — the model can emit a tool call but AI SDK does not invoke it. The call appears in result.toolCalls
  3. If any toolCalls entry matches a gated tool name, the adapter captures the full message history up to the pending call into a PausedAgentState and returns status: "awaiting_tool"
  4. executeAgentSession persists pausedState to sessions.metadata and flips the session to awaiting_tool
  5. createApprovalTask() inserts a child task + child session pair:
    • Task: slug: "approve-tool-<name>-<timestamp>", parent_id: parent task, assigned_to: <approver user id>, trigger_type: "manual", output_type: "status", metadata contains ApprovalTaskMetadata { isApprovalTask, pausedParentSessionId, pausedToolCall }
    • Session: task_id: new task, parent_id: parent session, status: "waiting_human", user_id: approver
  6. Because the new task has assigned_to, it appears in getUserTasks() — the same inbox surface as any other human-assigned work
  7. The user views the approval in either the task detail page (from the inbox) or inline in chat. Both surfaces render the same ToolApprovalCard component
  8. Submitting the decision hits POST /api/actions/[id]/tool-approvalcompleteToolApproval(approvalTaskId, decision):
    • Logs user.tool_confirmation on the parent session transcript (not the approval session) so the real run's history captures the decision
    • Marks the approval task + approval session completed
    • Fires EVENT_NAMES.SESSION_RESUME targeted at the parent session
  9. The sessionResume Inngest function picks up the event, loads the parent session, reads pausedState from metadata, and re-invokes executeAgentSession with { pausedState, decision, denialReason } as resume context
  10. executeAgentSession on the resume path skips claim_session (the session is already claimed), atomically transitions awaiting_tool → running, and passes the resume context to the adapter
  11. LocalSprinterAgent reconstructs the message history from pausedState.messageHistory, appends a tool-result message carrying the approval/denial decision, and calls executeAgentWithMessages() again — the agent continues as if the tool had executed

Approver resolution

resolveApprover() walks the session parent chain (sessions.parent_id) up to 16 iterations looking for the first non-null user_id. If no user is found (pure cron/heartbeat origin with no originator), it falls back to the first system_admin in user_tenants for the tenant. Returns null if neither is found — in which case the approval is created with a null assignee (a pending improvement would reject the tool call outright in that case, but v1 just surfaces the approval in the tenant's admin dashboard).

Key files

  • features/tools/types.tspermissionPolicy field, requiresApproval() helper
  • features/agents/providers/types.tsPausedAgentState, SprinterExecutionContext.resume, SprinterExecutionResult.pausedState
  • features/agents/providers/local.ts — fast/slow path split in LocalSprinterAgent.execute()
  • features/agents/runtime.tsexecuteAgentWithMessages() helper
  • features/actions/server/tool-approval.tscreateApprovalTask(), resolveApprover(), isApprovalTask(), ApprovalTaskMetadata
  • features/actions/server/execute.tsawaiting_tool branch + resume path
  • features/actions/server/hitl.tscompleteToolApproval()
  • features/inngest/functions/session-executor.tssessionResume Inngest function
  • app/api/actions/[id]/tool-approval/route.ts — approval endpoint
  • features/actions/components/tool-approval-card.tsx — reusable approval UI
  • features/tools/admin/index.tsADMIN_ALWAYS_ASK_TOOLS side-table
  • features/agents/lib/build-tools.ts — runtime permissionPolicy injection

Workflow default agents

Field-population and scoring actions resolve an assignee in this order:

  1. Action-level agent_slug
  2. Entity-type default action agent config
  3. Tenant agent default setting
  4. First enabled agent in the tenant whose role includes the required permissions

The required permissions are currently:

  • responses.team.create — lets the agent submit versioned responses
  • entities.team.update — lets the agent auto-promote approved field values

Configured defaults are only used when the selected agent is enabled and its role satisfies both permissions. Otherwise the runtime falls back to the first eligible agent and records the resolved slug/name on session metadata for debugging.

Heartbeat / Scheduled Execution

Phase 7 (2026-05-06) unified heartbeat scheduling under the actions registry:

  1. action-cron Inngest function fires every minute and scans for active cron action rows whose trigger_config.schedule matches the current minute.
  2. For each match, it emits actions/tick with { actionId, tenantId }.
  3. action-tick Branch 2 fires when action.agent_slug is set: it calls invokeAgentAutonomousRun() with the resolved agent.
  4. invokeAgentAutonomousRun() builds tools using the agent's role permissions and runs the agent with the heartbeat prompt from config.heartbeat.prompt.
  5. If eligible work exists, the heartbeat claims assigned actions and triggers them via triggerTask().
  6. Runs are tracked in the agent_heartbeat_runs table with status, duration, and output.
  7. Manual triggering is available via POST /api/agents/[id]/run (looks up the cron action row, emits actions/tick).

Heartbeat state is managed via setAgentHeartbeatAction() in features/agents/server/heartbeat-action.ts. The agent dialog calls this on save to upsert the cron action row.

External Agent Connections

The platform can connect to external AI agents via three protocols:

TypeDescription
OpenClawOpenClaw-compatible agent API
A2AAgent-to-Agent protocol
MCPModel Context Protocol

Connections are stored in the agent_connections table with:

  • url -- Endpoint URL
  • credentials -- Encrypted JSONB with API keys, tokens, etc.
  • status -- Connection health status
  • config -- Protocol-specific configuration
  • instance_id -- FK to managed_agent_instances (nullable) — links the connection to the runtime box that hosts it

API routes support connection CRUD, testing (validates credentials and network), and discovery (queries available agents on A2A connections).

Source and publishing connections

The same agent_connections table is also the credential store for authenticated content sources and outbound publishing destinations. Connection records now support:

  • Capability metadata in config.capabilities so the UI can filter connections for source ingestion vs. publishing workflows without adding a second credential model.
  • Preset metadata in config.presetId so admin users can create opinionated connection profiles such as WordPress content hubs, generic authenticated publications, LinkedIn publishing, Facebook page publishing, and generic publishing webhooks.
  • Structured auth modes (oauth2, bearer, apiKeyHeader, apiKeyQuery, basic, cookie, rawJson) surfaced in the connection dialog instead of forcing operators to hand-author credentials JSON.
  • OAuth connection metadata in config.oauth* fields so admins can see connection state, scopes, and expiry without exposing secret values to the client.

This keeps agents, source sync, and user-facing publishing tools on one shared connection primitive. A source can reference content.connection_id, while a workflow or tool can resolve the same saved connection by ID or by name when publishing a draft or post.

Managed Agent Instances

Dedicated runtime boxes (OpenClaw instances, sandbox environments, etc.) are tracked in the managed_agent_instances table. Each row represents one deployed runtime:

interface ManagedAgentInstance {
  id: string;
  tenant_id: string;
  name: string;
  environment: string;       // "production" | "staging" | custom
  status: string;            // "provisioning" | "healthy" | "degraded" | "error" | "terminated"
  connector_url: string | null;    // Public endpoint for the connector
  management_url: string | null;   // Tailnet/private management UI URL
  tailscale_hostname: string | null;
  tailscale_ip: string | null;
  network_scope: string | null;
  runtime_type: string;      // "openclaw" | "sandbox" | etc.
  config: Record<string, unknown>;
  notes: string | null;
  last_health_check: string | null;  // ISO timestamp of last health probe
  last_error: string | null;
  created_at: string;
  updated_at: string;
}

Instances are visible on Admin > Platform via the ManagedInstancesPanel component, which shows status, URLs, linked agents, and the last recorded error for each box. Multiple connections can belong to one instance — the panel derives linked agents by following agent_connections.instance_id back to agents that reference those connections.

Server actions for managed instances live in features/managed-agents/server/actions.ts: getInstances, getInstanceById, createInstance, updateInstance, deleteInstance. All actions are scoped to the active tenant and use the admin client.

Shared Credential Resolver

All connection auth-header building is centralized in lib/connections/resolve-headers.ts:

function resolveConnectionAuthHeaders(connection: {
  encrypted_credentials: string | null;
  config: Record<string, unknown>;
}): Record<string, string>

The function builds Authorization, Cookie, and custom transport headers from the connection's encrypted credentials and config, handling:

  • OAuth access tokens stored under credentials.oauth2
  • Bearer token (token or apiKey credential key)
  • Cookie credential
  • Basic Auth (basicAuth.username + basicAuth.password)
  • Nested credential headers (credentials.headers)
  • Config-level static headers (config.headers)
  • User-Agent override (config.userAgent), defaulting to "Amble/1.0 (Source Sync)"

Accept and Content-Type are intentionally excluded — those are caller-specific. The function is used by connection-actions.ts (connection testing), source-sync.ts (feed polling), and any future code that makes outbound requests on behalf of a connection.

OAuth-backed connections

Connections can now run a generic OAuth authorization-code flow without introducing a second credential table.

  • Client secrets stay encrypted inside agent_connections.encrypted_credentials.
  • Non-secret state is mirrored into config.oauthStatus, config.oauthConnectedAt, config.oauthExpiresAt, config.oauthScope, and config.oauthTokenType.
  • The admin UI can start or reconnect an OAuth session through:
    • GET /api/agent-connections/:id/oauth/start
    • GET /api/agent-connections/:id/oauth/callback
    • POST /api/agent-connections/:id/oauth/refresh
  • OAuth refresh is additive to the existing model. Agents, source sync, publishing tools, and MCP bridges still consume the same connection row, and they now opportunistically refresh expired OAuth tokens before making outbound requests.

The first preset-backed providers using this path are Canva, Dropbox, Google Analytics, and LinkedIn. Any other API connector can opt in by setting config.oauth manually in the connection's advanced config.

OpenClaw health checks

testConnection() in connection-actions.ts probes OpenClaw endpoints using a POST request (matching the actual chat/responses contract). An empty body is sent intentionally — OpenClaw returns HTTP 400 for an invalid request, which proves the endpoint is reachable. HTTP 200 and 400 are both treated as healthy; any other status marks the connection as errored. The accepted statuses are controlled by the module-level constant:

/** HTTP statuses that indicate a healthy OpenClaw probe response. */
const OPENCLAW_PROBE_OK_STATUSES = new Set([200, 400]);

The probe also validates that the response carries a JSON Content-Type, catching misconfigured reverse proxies that return HTML error pages.

Agent Delegation

Agents can delegate tasks to other agents using createDelegateToAgentTool() from features/agents/lib/delegate.ts. This creates an AI SDK tool that:

  1. Accepts an agentSlug and task description.
  2. Internal delegation -- Resolves the target agent from the database and runs it inline.
  3. External delegation -- Looks up the agent via agent_connections and routes through the connection-based provider.
  4. Returns a structured DelegationResult (see below) — the parent agent receives both the sub-agent's response text AND a full trace of every tool call it made.

The delegation tool is automatically included in every agent's ToolSet.

Result surfacing — DelegationResult shape

interface ToolCallTrace {
  toolCallId: string;          // matches the AI-SDK part id
  tool: string;                // e.g. "manageView"
  input?: unknown;             // exact input the sub-agent passed
  output?: unknown;            // exact output (truncated >4096 bytes — see below)
  errorText?: string;          // present iff the call errored
}

interface DelegationResult {
  agentName: string;
  agentSlug: string;
  response: string;
  toolCalls: ToolCallTrace[];           // source of truth — walk this
  toolsUsed?: Record<string, number>;   // derived: per-tool call counts
  alteredEntities?: ...;                // derived: createEntity/updateEntity/batch*
  relationsCreated?: ...;               // derived: createRelation/batchCreateRelations
  searchesMade?: ...;                   // derived: searchEntities + paired queries
  toolErrors?: ...;                     // derived: any output-error part
  metrics?: { inputTokens: number; outputTokens: number };
}

toolCalls is the source of truth. Every completed tool call the sub-agent made (any internal tool, any MCP tool, any custom tool) lands here automatically — no per-tool sniffer code, no blind spots. The denormalized fields are derived from toolCalls via summarizeDelegationToolCalls() in features/agents/lib/summarize-delegation.ts; they're kept for backward compatibility with existing chat UI / tests but new consumers should walk the trace directly.

Persistence truncation

Per-call outputs are bounded at 4096 bytes via truncateToolOutput() in features/agents/lib/render-delegation.ts. Outputs that exceed the cap are replaced with { kind: "truncated", preview, byteLength } before serialization to keep messages.parts and session_events.metadata from blowing up on a 50KB search-result blob. Inputs are stored verbatim (typically under 2KB).

toModelOutput shaping

The delegateToAgent tool registers a toModelOutput callback that rewrites what the parent LLM sees in its tool-result message: a synthesised text block with the agent name, the sub-agent's response, a "What changed" list of artifacts (with tenant-scoped URLs via tenantUrl), an "Errors" section, and a tool-call trace. The full structured object still streams to the UI / persists to messages.partstoModelOutput only affects model-facing context. See renderDelegationForLLM() for the exact format.

Sub-agent contract

Every delegated sub-agent's system prompt includes DELEGATION_SUMMARY_INSTRUCTION (exported from build-context.ts) via extraSystemSections. The contract requires the sub-agent to state outcomes plainly with IDs and tenant-scoped URLs, list concrete artifacts produced, flag failures explicitly by tool name, and never invent IDs or claim success after a tool failure. Lives on the cache-stable system-prompt prefix so user-message prompt injection cannot override it.

Render branch

features/chat/components/delegate-result-card.tsx provides a rich UI for DelegationResult. Because tool-call-card.tsx is the single tool-result renderer (chat live + history, inbox group conversations, post-hoc session / heartbeat-run transcripts via session-event-row.tsx), one branch addition lights up every viewing surface uniformly.

Call sites

createDelegateToAgentTool is wired into three execution surfaces today:

  • app/api/chat/route.ts — chat
  • features/chat/server/inbox-actions.ts — inbox group conversations
  • features/inngest/functions/action-tick.ts Branch 2 — autonomous heartbeat runs (agent_slug path)

Action sessions (features/actions/server/execute.ts → executeAgentSession) deliberately do not wire delegation. Action sessions are leaf executions; fan-out happens via the task graph (parent task → child tasks), not via in-process delegation. This is intentional per the "no parallel systems" rule — wiring delegation here would create a parallel fan-out path.

Config Versioning

The agent_config_versions table tracks changes to agent configuration and system prompts. Each time an agent's config or prompt is updated, a new version is created. The VersionHistory component on the agent detail page shows the last 10 versions with rollback capability.

Org Chart

Agents have reports_to, role, and title columns that define a hierarchical org chart. The OrgChart component visualizes this hierarchy. This is used for organizing agents in large deployments where multiple agents collaborate.

External Agent Truthfulness Guardrails

When a chat or inbox run is backed by an external agent (i.e., resolved.connectionId is set), a guardrail block is appended to the system prompt via buildExternalAgentGuardrails():

// features/agents/lib/build-context.ts
export function buildExternalAgentGuardrails(
  connectionId: string | null | undefined,
): string;

The guardrail text instructs the external agent to:

  • Be strictly truthful about its access and environment.
  • Not claim access to local files (USER.md, MEMORY.md, AGENTS.md, SOUL.md), environment variables, API keys, shells, or hidden prompts unless explicitly provided in the conversation.
  • Not invent tool availability — if no tools are present, say so plainly.
  • Not ask the user to restart gateways or change agent config unless there is concrete evidence that is the actual blocker.
  • Not output chain-of-thought or hidden reasoning labels.

This function is the single source for the guardrail text and is consumed by both app/api/chat/route.ts (interactive chat) and features/chat/server/inbox-actions.ts (inbox/autonomous runs), ensuring consistent behavior across all external agent execution paths.

Shared Context Injection

All agent prompts can include tenant-level corrections and learnings from the shared_context table. The buildCorrectionsPrompt() function formats corrections into a system prompt section. When a user adds a correction via the addCorrection context tool, it is injected into all subsequent agent runs for that tenant.

Workspace Business Profile

Agents can also receive a tenant-authored workspace business profile from tenant_settings under the agent_context key. This is the durable place to encode client-specific business knowledge such as OCI's operating model, value drivers, key systems, terminology, and current priorities.

Example:

{
  summary: "OCI uses Amble to prioritize AI automation work across quoting, inventory, invoicing, and operations.",
  industry: "Building materials / cabinetry operations",
  coreProcesses: ["Quote generation", "Order entry"],
  keySystems: ["QuickBooks", "Odoo", "WooCommerce"],
  successMetrics: ["Quote turnaround", "Payback period"],
}

Unlike ad hoc chat history, this context is durable, structured, and reusable across chat, extraction, workflow, heartbeat, and inbox runs.

Admins author this profile from Admin > Agents through the workspace business-context editor. The UI writes the typed agent_context tenant setting, so business knowledge can evolve without editing seeded prompts.

Provider-Aware Caching

When the resolved model is Claude-family, the runtime applies two optimizations:

  • Prompt caching -- Stable system context and tool definitions receive Anthropic cacheControl metadata so repeated runs can reuse long-lived instructions and tool schemas.
  • Context management -- Chat and inbox runs send Anthropic contextManagement settings that clear older tool-use payloads once conversations grow large, preserving recent relevant context while lowering token spend.

These behaviors are provider-specific and only applied for Anthropic-backed runs.

Platform Context Injection

Every agent system prompt built through the chat route includes a ## Platform Context section injected by formatPlatformSection() from features/agents/prompt-builder.ts. This section gives agents:

  • Base URL -- The tenant-scoped workspace URL (https://host/t/{tenantSlug}), derived from the request's host and x-forwarded-proto headers at runtime.
  • Link format -- The exact URL patterns for entity records and list pages.
  • Available pages -- dashboard, chat, feed, activity, graph, documents, plus one URL per entity type.
  • Capabilities -- A summary of entity CRUD, search, relations, chat, document upload, workflows, and field extraction.
// Injected section example
## Platform Context
You are an agent on the **Acme Corp** workspace in the Sprinter Platform.

**Base URL:** https://app.example.com/t/acme
**Link format:** `https://app.example.com/t/acme/{typeSlug}/{entityId}` for records
**Pages:** dashboard, chat, feed, activity, graph, documents, plus data type pages: `…/opportunity`, `…/person`
**Capabilities:** entity CRUD, search, relations, chat, document upload, workflows, field extraction

When referencing records, use markdown links: `[Record Title](https://…/{typeSlug}/{id})`

This section appears before entity-types in the composed prompt so agents have URL context when they read the entity type list. The platform section uses no extra DB queries — entity type slugs come from the already-fetched entityTypes list.

The PromptSection union type in prompt-builder.ts includes a "platform" variant:

| {
    type: "platform";
    baseUrl: string;
    tenantSlug: string;
    tenantName: string;
    features: string[];  // entity type slugs
  }

API Reference

Prompt Builder (features/agents/prompt-builder.ts)

Pure functions for composing agent system prompts from typed sections:

FunctionDescription
buildAgentPrompt(basePrompt, sections)Compose a full system prompt from a base prompt and an ordered list of PromptSection values.
formatPlatformSection(params)Build the ## Platform Context section with base URL, link format, available pages, and capabilities.

Section types: "entity-types", "session", "corrections", "entity-context", "memories", "platform".

Context Builder (features/agents/lib/build-context.ts)

FunctionDescription
loadAgentPromptContext(params)Load all runtime context (corrections, entity context, workspace profile) for an agent run.
buildAgentSystemPrompt(params)Compose the full system prompt from a base prompt and loaded context.
buildExternalAgentGuardrails(connectionId)Returns a guardrail block for external agents, or an empty string for internal agents.

Tool Resolution (features/agents/resolve-tools.ts)

FunctionSignatureDescription
resolveAgentTools(config, permissions?)(config: unknown, permissions?: AppPermission[]) => ToolSetBuild a ToolSet from an agent's config with permission filtering.
dbAgentToDefinition(dbAgent, permissions?)Returns AgentDefinition + metadataConvert a DB agent record to a code-compatible definition.

Runtime (features/agents/runtime.ts)

FunctionSignatureDescription
executeAgent(config)(config: AgentRunConfig) => Promise<StreamTextResult>Execute an agent via streamText with cost tracking.
collectAgentResponse(result)Returns { parts, text, usage }Collect full response from a stream result.
buildAgentRunTelemetry(input)AgentRunTelemetryExtract cache, reasoning, and context-management telemetry from a completed run.

Types (features/agents/types.ts)

ExportDescription
AgentInfoDisplay metadata for agents in UI (id, name, description, icon, suggestions).
AgentConfigTyped config shape (toolGroups, customTools, skills, heartbeat).
HeartbeatScheduleHeartbeat configuration (enabled, schedule, prompt, mode, maxDuration, timezone).
TOOL_GROUPSConst object mapping group names to string identifiers.
TOOL_GROUP_LABELSHuman-readable labels for tool groups.
parseAgentConfig(config)Safely parse raw JSONB into typed AgentConfig.

Connection Types (features/agents/connection-types.ts)

ExportDescription
AgentConnectionStatusUnion type: "active" | "inactive" | "error".
CONNECTION_STATUS_CONFIGMaps status to dot color and label string for display.
CONNECTION_STATUS_BADGE_VARIANTMaps status to shadcn/ui Badge variant ("default", "secondary", "destructive").
AgentConnectionInternalServer-side connection record including encrypted credentials blob. Never return via API.

Managed Instance Actions (features/managed-agents/server/actions.ts)

FunctionSignatureDescription
getInstances()() => Promise<ManagedAgentInstance[]>List all instances for the active tenant, ordered by name.
getInstanceById(id)(id: string) => Promise<ManagedAgentInstance | null>Fetch a single instance by UUID.
createInstance(input)Returns ManagedAgentInstanceCreate a new instance row. runtime_type defaults to "openclaw", environment defaults to "production".
updateInstance(id, input)Returns ManagedAgentInstancePartial update; only explicitly provided keys are sent. Throws if the instance is not found or tenant does not match.
deleteInstance(id)(id: string) => Promise<void>Delete an instance. Connected agent_connections have their instance_id set to NULL via FK cascade.

Admin Pages

PathDescription
/admin (Agents tab)Agent list with create/edit/delete, heartbeat config
/admin/agents/[id]Agent detail: run history, heartbeat config, manual trigger, execution tracing
/admin/agents/[id] (Versions tab)Config version history with rollback
/admin (Connections tab)External connection management with test/discover
/admin (Jobs tab)Background job dashboard (heartbeat runs, extraction, etc.)
/admin/platformManaged instance registry with health status, URLs, and linked agents

For Agents

Agents can interact with other agents through:

  • Delegation -- delegateToAgent(agentSlug, task) routes work to another agent (internal or external).
  • Agent config tool -- updateAgentConfig (admin tool) lets agents modify other agents' configurations.
  • Context tools -- addCorrection and addLesson write to shared context that all agents read.
  • Usage telemetry -- getUsageStats returns cost, prompt-caching, and runtime telemetry for the workspace or a specific agent.

Agents running in chat can access external MCP servers through the gateway tools:

  • listMcpTools -- Discover available tools from connected MCP servers. Accepts an optional serverName to filter to one server. Returns server names, tool names, and descriptions.
  • callMcpTool -- Execute a tool on a named MCP server. Requires serverName, toolName, and optional arguments. Use listMcpTools first to confirm the tool name and expected arguments.

The description of listMcpTools always lists the currently connected server names inline (e.g., Connected servers: slack, github), so agents do not need to call the tool just to know what servers are available.

When an agent is used in chat, it receives entity context if the chat is scoped to a specific entity. The system prompt includes the entity's type, title, and key fields so the agent can provide contextual responses.

Design Decisions

MCP tools are gated behind a 2-tool gateway, not loaded flat. Loading every MCP tool schema directly into the ToolSet would grow the prompt proportionally as admins add connections. The 2-tool gateway (listMcpTools / callMcpTool) keeps the ToolSet size and the prompt prefix stable regardless of how many MCP servers are connected. This is the key invariant for Anthropic prompt caching: a stable prefix means the cache hit rate stays high even as MCP server tool catalogs grow.

Gateway only for MCP, not internal tools. Internal tools are already scoped per agent (~10–20 tools). Routing them through a gateway would add a round trip with no benefit. The gateway pattern is justified specifically because MCP tool catalogs are external, unbounded, and would otherwise consume unbounded context.

headers instead of apiKey on McpServerConfig. getMcpServerConfigs calls resolveConnectionAuthHeaders() from the shared credential resolver, so MCP connections get free support for Bearer tokens, cookie auth, basic auth, and custom headers — the same patterns used by OpenClaw and source-sync connections.

DB-only runtime. Agents live in the agents table. System agents come from product.config.ts, while tenant agents provide runtime customization without code changes. The resolver backfills missing system rows before falling back to the first enabled agent.

Tool groups instead of individual tool lists. Rather than listing every tool by name, agents declare tool groups (e.g., ["entity", "web"]). This keeps agent config concise and automatically includes new tools when a group is expanded. Individual tools can still be added via customTools for fine-grained control.

Permission exclusion, not permission checking. Tools the caller lacks permission for are removed from the ToolSet entirely. Agents never see tool descriptions or schemas for unavailable tools. This prevents agents from attempting to use tools they cannot access and avoids confusing error messages.

Fail-closed for entity tools. getEntityTools() returns an empty object when no permissions are passed. Every caller must explicitly declare what permissions they have. This prevents accidentally giving an unpermissioned agent access to entity CRUD.

Unified runtime for all execution paths. Chat, heartbeat, extraction, delegation, and workflow execution all use executeAgent(). This ensures consistent cost tracking, model resolution, and error handling across all agent execution contexts.

Prompt regressions are tested explicitly. features/agents/prompt-regression.test.ts locks in the core language expected in seeded system prompts and the OCI workspace-context fixture so prompt quality changes stay intentional.

External agent guardrails are injected at the platform layer, not the agent layer. External agents (OpenClaw, A2A) can define arbitrary system prompts on their own infrastructure. The platform adds buildExternalAgentGuardrails() on top so truthfulness constraints are enforced even when the remote agent's prompt does not include them. This is not negotiable by the agent definition.

HTTP 400 is a healthy OpenClaw probe response. OpenClaw's chat and responses endpoints return 400 when the request body is invalid (e.g., an empty {}). A 400 from a live endpoint is more informative than a 200 from a reverse proxy returning an HTML "service unavailable" page. Accepting 400 as healthy and separately validating the JSON Content-Type provides a stronger signal than status code alone.

Agent role_id for autonomous permissions. Rather than inventing a separate permission model for agents, agents share the same roles + role_permissions system as users. An agent's role_id determines what it can do when running autonomously. This keeps the permission model simple and auditable.

External agents as first-class citizens. The connection system (OpenClaw, A2A, MCP) and dbAgentToDefinition() bridge let external agents participate in delegation, chat, and heartbeat just like internal agents. The abstraction is at the model level -- resolveExternalModel() returns an AI SDK-compatible model that routes to the external provider.

Managed instances as a separate table from connections. agent_connections is a per-agent credential record; a single physical runtime box can host many agents and therefore many connections. Promoting instance metadata from a crammed config JSON blob to a proper managed_agent_instances table enables FK links, queryable status, and a health dashboard. The FK is nullable so connections that were created before the migration (or that don't belong to a managed runtime) remain valid.

Shared credential resolver in lib/, not features/. resolveConnectionAuthHeaders() has no dependency on any platform feature — it only imports the crypto utility. Placing it in lib/connections/ keeps the dependency graph clean and lets it be imported by any module (Inngest background jobs, API routes, server actions) without creating cross-feature imports.

Brief-generation flow (ADR-0021)

Bundle-shipped content generators (executive summaries, diligence memos, assessment reports) follow the thin tool + DAG-driven field extraction pattern. A single composer agent (e.g. executive-summary-composer in the CBT bundle) handles all per-field extraction tasks. The platform's task DAG sequences them via Kahn's algorithm; Inngest fans out independent layer-1 tasks in parallel and joins on completion before running layer-2 tasks (e.g. headline distillation, citations-union validation).

The agent uses generateObject() (not streamText()) with a Zod schema enforcing claims[] = {text, source_entity_ids[]} per claim. Citations are validated by a dedicated layer-2 task that unions all claim citations and rejects entity ids not in the input engagement's connected-entity set — a hard guard against fabricated ids. Live progress surfaces in the chat via session_events rows with kind: "progress", rendered inline in the agent's chat bubble (features/chat/components/session-event-progress.tsx).

See Tool System — Thin tool + DAG-driven field extraction for the authoring checklist and reference implementation.

  • Tool System -- Agents receive tools via the tool resolution pipeline
  • Entity System -- Entity tools are the most common tool group for agents
  • View System -- Agents author views via admin tools
  • Auth and Permissions -- Agent permissions use the same RBAC system as users
  • Field Population -- Agents use specialized tools to produce reviewed response values
  • Actions and Sessions -- Heartbeat agents claim actions and execute them through sessions

On this page