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:
| Agent | ID | Tool Groups | Purpose |
|---|---|---|---|
| Primary assistant | Configured in product.config.ts | entity | General intelligence -- search, create, analyze the entity graph |
| Analyst | analyst | entity | Deep analysis, scoring, prioritization, ROI assessment |
| Researcher | researcher | entity, web | Research, 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
rolestable, 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_connectionsfor 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:
- Parse config --
parseAgentConfig(config)extractstoolGroupsandcustomToolsarrays. - Tool group expansion -- Each tool group maps to a function:
entitycallsgetEntityTools(permissions)+getDocumentTools()webcallsgetWebTools()workflowcallsgetWorkflowTools(permissions)contextcallsgetContextTools(permissions)admincallsgetAdminTools(permissions)+getViewTools(permissions)mediacallsgetMediaTools(permissions)
- 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.
- Custom tool inclusion -- Individual tool slugs in
customToolsare pulled from the user-facing tool registry. - Permission filtering -- Each tool function receives the caller's
permissionsarray 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:
- Model resolution -- Internal agents use
resolveModel(). External agents (withconnectionId) useresolveExternalModel()with connection credentials. - Prompt shaping -- Stable context is prepended as system messages, which keeps prompt assembly composable and enables provider-level caching.
- Tool normalization -- Tool definitions are sorted deterministically before execution so stable tool schemas can be reused by provider caching.
- Execution -- Calls
streamText()with the resolved model, prepared prompt messages, tools, and step limit. - Cost tracking --
onFinishrecords token usage to the cost tracking system (fire-and-forget). - 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:
| Tool | Purpose |
|---|---|
listMcpTools | Discover tools from one or all connected MCP servers |
callMcpTool | Execute a named tool on a specific server |
How it flows:
app/api/chat/route.tscallsgetMcpServerConfigs(tenantId)in parallel with the prompt context load (no sequential waterfall).- The resulting
McpServerConfig[]is passed tobuildInternalAgentTools()via themcpConfigsparam. buildInternalAgentTools()callscreateMcpGatewayTools(configs)fromfeatures/mcp/gateway-tools.ts, which returns{}when configs is empty and the 2-tool gateway ToolSet when at least one MCP connection is active.- The 2 tools are merged into the agent's ToolSet alongside internal tools, delegation, and skill loading.
- At inference time the agent calls
listMcpToolsto discover available tools on a server, thencallMcpToolto 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:
| Context | Example | Permissions Source |
|---|---|---|
| Supervised | User chatting with an agent | getUserPermissions() -- agent inherits user's permissions |
| Autonomous | Heartbeat cron job, extraction trigger | getPermissionsForRole(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
ToolDefinitionsetpermissionPolicy: "always_ask"directly on the definition - Admin tools (inline AI SDK
Toolobjects) are listed inADMIN_ALWAYS_ASK_TOOLSinfeatures/tools/admin/index.ts.buildInternalAgentTools()attachespermissionPolicyat runtime (AI SDK'sTooltype 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
LocalSprinterAgentscans tools forpermissionPolicy: "always_ask". If any are present, it takes a "slow path" that stripsexecutefrom those tools and callsexecuteAgentWithMessages()instead ofexecuteAgentSync()- Because the gated tools have no
executefunction, 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 inresult.toolCalls - If any
toolCallsentry matches a gated tool name, the adapter captures the full message history up to the pending call into aPausedAgentStateand returnsstatus: "awaiting_tool" executeAgentSessionpersistspausedStatetosessions.metadataand flips the session toawaiting_toolcreateApprovalTask()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 containsApprovalTaskMetadata { isApprovalTask, pausedParentSessionId, pausedToolCall } - Session:
task_id: new task,parent_id: parent session,status: "waiting_human",user_id: approver
- Task:
- Because the new task has
assigned_to, it appears ingetUserTasks()— the same inbox surface as any other human-assigned work - The user views the approval in either the task detail page (from the inbox) or inline in chat. Both surfaces render the same
ToolApprovalCardcomponent - Submitting the decision hits
POST /api/actions/[id]/tool-approval→completeToolApproval(approvalTaskId, decision):- Logs
user.tool_confirmationon 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_RESUMEtargeted at the parent session
- Logs
- The
sessionResumeInngest function picks up the event, loads the parent session, readspausedStatefrom metadata, and re-invokesexecuteAgentSessionwith{ pausedState, decision, denialReason }as resume context executeAgentSessionon the resume path skipsclaim_session(the session is already claimed), atomically transitionsawaiting_tool → running, and passes the resume context to the adapterLocalSprinterAgentreconstructs the message history frompausedState.messageHistory, appends a tool-result message carrying the approval/denial decision, and callsexecuteAgentWithMessages()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.ts—permissionPolicyfield,requiresApproval()helperfeatures/agents/providers/types.ts—PausedAgentState,SprinterExecutionContext.resume,SprinterExecutionResult.pausedStatefeatures/agents/providers/local.ts— fast/slow path split inLocalSprinterAgent.execute()features/agents/runtime.ts—executeAgentWithMessages()helperfeatures/actions/server/tool-approval.ts—createApprovalTask(),resolveApprover(),isApprovalTask(),ApprovalTaskMetadatafeatures/actions/server/execute.ts—awaiting_toolbranch + resume pathfeatures/actions/server/hitl.ts—completeToolApproval()features/inngest/functions/session-executor.ts—sessionResumeInngest functionapp/api/actions/[id]/tool-approval/route.ts— approval endpointfeatures/actions/components/tool-approval-card.tsx— reusable approval UIfeatures/tools/admin/index.ts—ADMIN_ALWAYS_ASK_TOOLSside-tablefeatures/agents/lib/build-tools.ts— runtimepermissionPolicyinjection
Workflow default agents
Field-population and scoring actions resolve an assignee in this order:
- Action-level
agent_slug - Entity-type default action agent config
- Tenant agent default setting
- 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 responsesentities.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:
action-cronInngest function fires every minute and scans for active cron action rows whosetrigger_config.schedulematches the current minute.- For each match, it emits
actions/tickwith{ actionId, tenantId }. action-tickBranch 2 fires whenaction.agent_slugis set: it callsinvokeAgentAutonomousRun()with the resolved agent.invokeAgentAutonomousRun()builds tools using the agent's role permissions and runs the agent with the heartbeat prompt fromconfig.heartbeat.prompt.- If eligible work exists, the heartbeat claims assigned actions and triggers them via
triggerTask(). - Runs are tracked in the
agent_heartbeat_runstable with status, duration, and output. - Manual triggering is available via
POST /api/agents/[id]/run(looks up the cron action row, emitsactions/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:
| Type | Description |
|---|---|
| OpenClaw | OpenClaw-compatible agent API |
| A2A | Agent-to-Agent protocol |
| MCP | Model 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.capabilitiesso the UI can filter connections for source ingestion vs. publishing workflows without adding a second credential model. - Preset metadata in
config.presetIdso 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 (
tokenorapiKeycredential 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, andconfig.oauthTokenType. - The admin UI can start or reconnect an OAuth session through:
GET /api/agent-connections/:id/oauth/startGET /api/agent-connections/:id/oauth/callbackPOST /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:
- Accepts an
agentSlugandtaskdescription. - Internal delegation -- Resolves the target agent from the database and runs it inline.
- External delegation -- Looks up the agent via
agent_connectionsand routes through the connection-based provider. - 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.parts — toModelOutput 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— chatfeatures/chat/server/inbox-actions.ts— inbox group conversationsfeatures/inngest/functions/action-tick.tsBranch 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
cacheControlmetadata so repeated runs can reuse long-lived instructions and tool schemas. - Context management -- Chat and inbox runs send Anthropic
contextManagementsettings 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'shostandx-forwarded-protoheaders 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:
| Function | Description |
|---|---|
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)
| Function | Description |
|---|---|
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)
| Function | Signature | Description |
|---|---|---|
resolveAgentTools(config, permissions?) | (config: unknown, permissions?: AppPermission[]) => ToolSet | Build a ToolSet from an agent's config with permission filtering. |
dbAgentToDefinition(dbAgent, permissions?) | Returns AgentDefinition + metadata | Convert a DB agent record to a code-compatible definition. |
Runtime (features/agents/runtime.ts)
| Function | Signature | Description |
|---|---|---|
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) | AgentRunTelemetry | Extract cache, reasoning, and context-management telemetry from a completed run. |
Types (features/agents/types.ts)
| Export | Description |
|---|---|
AgentInfo | Display metadata for agents in UI (id, name, description, icon, suggestions). |
AgentConfig | Typed config shape (toolGroups, customTools, skills, heartbeat). |
HeartbeatSchedule | Heartbeat configuration (enabled, schedule, prompt, mode, maxDuration, timezone). |
TOOL_GROUPS | Const object mapping group names to string identifiers. |
TOOL_GROUP_LABELS | Human-readable labels for tool groups. |
parseAgentConfig(config) | Safely parse raw JSONB into typed AgentConfig. |
Connection Types (features/agents/connection-types.ts)
| Export | Description |
|---|---|
AgentConnectionStatus | Union type: "active" | "inactive" | "error". |
CONNECTION_STATUS_CONFIG | Maps status to dot color and label string for display. |
CONNECTION_STATUS_BADGE_VARIANT | Maps status to shadcn/ui Badge variant ("default", "secondary", "destructive"). |
AgentConnectionInternal | Server-side connection record including encrypted credentials blob. Never return via API. |
Managed Instance Actions (features/managed-agents/server/actions.ts)
| Function | Signature | Description |
|---|---|---|
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 ManagedAgentInstance | Create a new instance row. runtime_type defaults to "openclaw", environment defaults to "production". |
updateInstance(id, input) | Returns ManagedAgentInstance | Partial 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
| Path | Description |
|---|---|
/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/platform | Managed 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 --
addCorrectionandaddLessonwrite to shared context that all agents read. - Usage telemetry --
getUsageStatsreturns 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 optionalserverNameto filter to one server. Returns server names, tool names, and descriptions.callMcpTool-- Execute a tool on a named MCP server. RequiresserverName,toolName, and optionalarguments. UselistMcpToolsfirst 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.
Related Modules
- 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
Tool System
Standalone calculators and utilities usable by humans (form UI) and AI agents (AI SDK ToolSet). Tool registration, permission gating, execution tracking, collaborative sessions, and the AI bridge.
Chat System
Multi-agent AI chat with conversation types, participant models, message persistence via AI SDK v6 parts, agent selection, entity scoping, and tool result rendering.