Documentation source
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:
```typescript
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 `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:
```typescript
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:
```typescript
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}`.
```typescript
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:
```typescript
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:
| Tool | Purpose |
|------|---------|
| `listMcpTools` | Discover tools from one or all connected MCP servers |
| `callMcpTool` | Execute 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:
| 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.
### The Feedback Loop (ADR-0027)
Every agent run is a candidate for learning. The platform threads four primitives into one closed loop so agents apply yesterday's correction to today's prompt.
```
intake (chat / response / tool / session / observation / eval-divergence)
↓
createFeedback / createSystemFeedback ← single write seam (D1)
↓
feedback table — typed pointers (message_id, response_id, tool_call_id) +
long-tail context jsonb
↓
├── Fast path: loadRelevantFeedback({entityId?, agentId?, sessionId?})
│ → adversarial block in next session's volatile prompt
│
└── Distilled: feedback-review cron action (default-enabled per tenant)
→ remember({content, kind}) ← canonical write tool (D2)
→ shared_context table
→ loadSharedContextPrompt() → system-prompt corrections section
```
**Key surfaces**
- `features/feedback/server/actions.ts` — `createFeedback()` (auth-bound) and `createSystemFeedback()` (admin-context, for Inngest workers / eval hooks).
- `features/feedback/server/queries.ts::loadRelevantFeedback` — fast path; OR of typed pointers, skips `applied/dismissed`.
- `features/feedback/components/feedback-button.tsx` + `feedback-composer.tsx` — thumbs-down + optional comment popover, used by response / tool-call / session surfaces.
- `features/tools/memory-tools.ts::remember` — single canonical write tool, exposes all five `SharedContextType` kinds (`correction | lesson | routing | insight | guideline`). Lives in tool group `"memory"`; `"context"` stays as a registered alias for one release.
- `features/agents/lib/build-context.ts` — fast-path adversarial feedback rendered as a `## Recent Negative Feedback` block in the volatile user message; distilled corrections render in the stable system-prompt prefix.
- `features/evals/server/feedback-seeds.ts` — `listFeedbackSeedCandidates` + `markFeedbackAsApplied` — when a replay seeded from a feedback row passes, the row transitions to `applied` (loop closes).
**Roles & defaults**
- The `feedback-review` system agent now runs under the bounded `ROLE_IDS.feedback_reviewer` role (entities.team.read|update + agents.team.update) — no admin escalation.
- The `feedback-review` cron action is enabled by default on every tenant (migration `20260516030000`); admins retain the opt-out toggle in the workspace settings UI.
**Cross-cutting**
- Source-type enum is now `chat | response | tool | session | observation` — `extraction` folded into `session` per ADR-0016.
- Typed pointer columns (`message_id`, `response_id`, `tool_call_id`) enable the eval reverse loop to join feedback → original artifact without parsing jsonb.
See ADR-0027 for the full rationale. The orchestration that fires `feedback-seeds → replay → mark-applied` automatically lives in the `evals-as-actions` follow-up.
### 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-approval` → `completeToolApproval(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.ts` — `permissionPolicy` field, `requiresApproval()` helper
- `features/agents/providers/types.ts` — `PausedAgentState`, `SprinterExecutionContext.resume`, `SprinterExecutionResult.pausedState`
- `features/agents/providers/local.ts` — fast/slow path split in `LocalSprinterAgent.execute()`
- `features/agents/runtime.ts` — `executeAgentWithMessages()` helper
- `features/actions/server/tool-approval.ts` — `createApprovalTask()`, `resolveApprover()`, `isApprovalTask()`, `ApprovalTaskMetadata`
- `features/actions/server/execute.ts` — `awaiting_tool` branch + resume path
- `features/actions/server/hitl.ts` — `completeToolApproval()`
- `features/inngest/functions/session-executor.ts` — `sessionResume` 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.ts` — `ADMIN_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 several protocols:
| Type | Description |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **OpenClaw** | OpenClaw-compatible agent API (OpenAI-compatible chat/responses surface) |
| **Claude Managed** | Anthropic Managed Agents API — stateful external sessions via `externalSessionId`; adapter at `features/agents/providers/claude-managed/` |
| **Hermes** | Hermes Agent API Server — third-party agent runtime with local memory/skills/tools/cron/delegation. Amble calls the Hermes Runs API (`/v1/runs`, polled) while owning the goal/session/handoff envelope. |
| **A2A** | Agent-to-Agent protocol |
| **MCP** | Model Context Protocol |
| **agent-orchestration bridge** | MCP bridge (not a runtime) — exposes Amble's model-agnostic orchestration scope and a `agent_handoff_prompt` builder so any connected agent can read the same handoff contract. See `features/mcp/bridges/agent-orchestration.ts`. |
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:
```typescript
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`:
```typescript
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:
```typescript
/** 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
```ts
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` — 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()`:
```typescript
// 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 receive a tenant-authored business profile from `tenant_settings` under the `agent_context` key. This is the durable place to encode client-specific business knowledge — the equivalent of a `CLAUDE.md` for the workspace.
The shape is intentionally minimal:
```typescript
{
markdown: "OCI is a cabinetry operator… Value drivers: quote speed, margin protection…",
references: [
{ entityId: "uuid-of-glossary-entity", label: "Quote pipeline definition" },
{ entityId: "uuid-of-systems-doc" },
],
}
```
`markdown` is free-form (12k char hard cap) and is rendered as-is into the agent system prompt. `references` resolves to live entity rows at prompt-build time — agents see the linked entities as deterministic context (similar to skills, but tenant-scoped data). Up to 10 references are rendered per prompt with a 600-char excerpt each; the rest are listed as "+N unresolved".
Unlike ad hoc chat history, this context is durable, scoped (tenant + workspace + per-user layers all apply), and reusable across chat, extraction, heartbeat, and inbox runs. The `loadAgentContextLayers()` resolver in `features/agents/agent-context.ts` returns each tier as its own value so the prompt builder can render them nested rather than collapsing into a single merged blob.
Admins author this profile from **Admin > Context** (replaces the legacy workspace-context editor on `/admin/agents`). The page lives at both tenant and workspace URL scopes — opening `/t/<slug>/admin/context` edits the tenant baseline; `/t/<slug>/w/<workspace>/admin/context` edits a workspace override. The reference picker pulls from `/api/search/global`, so any tenant entity can be wired in.
### 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.
```typescript
// 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:
```typescript
| {
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** -- `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 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](/docs/features/tool-system#thin-tool--dag-driven-field-extraction-adr-0021) for the authoring checklist and reference implementation.
## Related Modules
- [Tool System](/docs/features/tool-system) -- Agents receive tools via the tool resolution pipeline
- [Entity System](/docs/features/entity-system) -- Entity tools are the most common tool group for agents
- [View System](/docs/features/view-system) -- Agents author views via admin tools
- [Auth and Permissions](/docs/features/auth-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