Files
PaperClipAI/doc/spec/agent-runs.md
T
DottaandPaperclip 98d9360658 feat(adapters): confine local coding processes (#9504)
## Thinking Path

> - Paperclip is the open source control plane people use to manage AI
agents for work.
> - Local coding adapters currently spawn their CLI processes directly
on the Paperclip host.
> - CLI-native approval and sandbox flags do not provide a reliable host
filesystem or network boundary.
> - An agent can therefore inspect unrelated host files or fetch
external material when an operator needs stronger isolation.
> - The confinement must stay opt-in so existing local adapter behavior
does not change unexpectedly.
> - This pull request adds a shared Linux Bubblewrap spawn layer for
workspace filesystem and deny/allowlist network scopes.
> - The benefit is enforceable defense in depth around Codex and Claude
local runs while preserving explicit provider connectivity.

## Linked Issues or Issue Description

### What happened?

`codex_local` and `claude_local` processes could read arbitrary host
paths and make unrestricted outbound network requests because Paperclip
did not impose a spawn-level boundary.

### Expected behavior

Operators can opt into a workspace-only filesystem view and either deny
network egress or allow exact provider/API hosts, independently of CLI
approval flags.

### Steps to reproduce

1. Run current `master` on Linux and configure a Codex or Claude local
adapter.
2. Ask the agent to read a canary file outside its active workspace.
3. Ask the agent to `curl` a public host.
4. Observe that both operations succeed without a Paperclip-level
confinement option.

### Environment

- Paperclip commit: `c36f1a4af` / current `master` base.
- Deployment mode: Linux local dev or self-hosted server.
- Installation: built from source.
- Adapters: Codex and Claude Code.
- Database: not related.

## What Changed

- Added a shared Bubblewrap process wrapper with opt-in
`filesystemScope: "workspace"`, managed/extra path mounts, private
`/tmp`, and Linux-only validation.
- Added `networkScope: "deny" | "allowlist"`; both use a private network
namespace, while allowlist mode exposes an exact-host HTTP(S) proxy over
a Unix-socket bridge.
- Wired Codex and Claude local CLI execution through the wrapper and
forced scoped auto runs onto the CLI lane because ACP processes are not
covered.
- Added unit and gated Bubblewrap canaries for outside-file denial,
workspace writes, direct network denial, allowlisted forwarding, and
rejected destinations.
- Documented both scopes, provider allowlist examples, Bubblewrap
requirements, and default-off behavior.

## Verification

- `pnpm exec vitest run
packages/adapter-utils/src/local-process-sandbox.test.ts
packages/adapters/codex-local/src/server/acp.test.ts
packages/adapters/claude-local/src/server/acp.test.ts` — 34 passed, 4
gated Bubblewrap tests skipped by default.
- `pnpm --filter @paperclipai/adapter-utils typecheck`
- `pnpm --filter @paperclipai/adapter-codex-local typecheck`
- `pnpm --filter @paperclipai/adapter-claude-local typecheck`
- `pnpm --filter @paperclipai/adapter-utils build`
- `pnpm --filter @paperclipai/adapter-codex-local build`
- `pnpm --filter @paperclipai/adapter-claude-local build`
- Attempted the gated tests with a vendored Bubblewrap binary; this
container blocks unprivileged namespace setup (`setting up uid map:
Permission denied` / loopback `RTM_NEWADDR: Operation not permitted`),
so kernel-level execution remains for CI or a namespace-enabled Linux
host.

## Risks

- Bubblewrap must be installed and unprivileged user/mount/network
namespaces must be enabled on the host; scoped runs fail clearly if the
prerequisite is missing.
- Allowlist mode depends on the coding CLI honoring standard
`HTTP_PROXY` / `HTTPS_PROXY` variables; custom providers must list every
required exact hostname and port.
- Exact-host allowlists intentionally reject wildcards, which is safer
but may require operators to enumerate multi-host provider setups.
- No behavior changes unless an operator enables `filesystemScope` or
`networkScope`.

> This aligns with the ROADMAP direction toward safer remote and
sandboxed agent environments and does not duplicate an open PR or issue
found in the repository search.

## Model Used

- OpenAI GPT-5.5 (`gpt-5.5`) via Codex CLI, with reasoning, repository
tool use, shell execution, code editing, and test execution. The serving
context-window size is not exposed to the agent.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-14 11:01:19 -05:00

775 lines
26 KiB
Markdown

# Agent Runs Subsystem Spec
Status: Draft
Date: 2026-02-17
Audience: Product + Engineering
Scope: Agent execution runtime, adapter protocol, wakeup orchestration, and live status delivery
## 1. Document Role
This spec defines how Paperclip actually runs agents while staying runtime-agnostic.
- `doc/SPEC-implementation.md` remains the V1 baseline contract.
- This document adds concrete subsystem detail for agent execution, including local CLI adapters, runtime state persistence, wakeup scheduling, and browser live updates.
- If this doc conflicts with current runtime behavior in code, this doc is the target behavior for upcoming implementation.
## 2. Captured Intent (From Request)
The following intentions are explicitly preserved in this spec:
1. Paperclip is adapter-agnostic. The key is a protocol, not a specific runtime.
2. We still need default built-ins to make the system useful immediately.
3. First two built-ins are `claude-local` and `codex-local`.
4. Those adapters run local CLIs directly on the host machine, unsandboxed.
5. Agent config includes working directory and initial/default prompt.
6. Heartbeats run the configured adapter process, Paperclip manages lifecycle, and on exit Paperclip parses JSON output and updates state.
7. Session IDs and token usage must be persisted so later heartbeats can resume.
8. Adapters should support status updates (short message + color) and optional streaming logs.
9. UI should support prompt template "pills" for variable insertion.
10. CLI errors must be visible in full (or as much as possible) in the UI.
11. Status changes must live-update across task and agent views via server push.
12. Wakeup triggers should be centralized by a heartbeat/wakeup service with at least:
- timer interval
- wake on task assignment
- explicit ping/request
## 3. Goals and Non-Goals
### 3.1 Goals
1. Define a stable adapter protocol that supports multiple runtimes.
2. Ship production-usable local adapters for Claude CLI and Codex CLI.
3. Persist adapter runtime state (session IDs, token/cost usage, last errors).
4. Centralize wakeup decisions and queueing in one service.
5. Provide realtime run/task/agent updates to the browser.
6. Support deployment-specific full-log storage without bloating Postgres.
7. Preserve company scoping and existing governance invariants.
### 3.2 Non-Goals (for this subsystem phase)
1. Distributed execution workers across multiple hosts.
2. Third-party adapter marketplace/plugin SDK.
3. Perfect cost accounting for providers that do not emit cost.
4. Long-term log archival strategy beyond basic retention.
## 4. Baseline and Gaps (As of 2026-02-17)
Current code already has:
- `agents` with `adapterType` + `adapterConfig`.
- `heartbeat_runs` with basic status tracking.
- in-process `heartbeatService` that invokes `process` and `http`.
- cancellation endpoints for active runs.
Current gaps this spec addresses:
1. No persistent per-agent runtime state for session resume.
2. No queue/wakeup abstraction (invoke is immediate).
3. No assignment-triggered or timer-triggered centralized wakeups.
4. No websocket/SSE push path to browser.
5. No persisted run event timeline or external full-log storage contract.
6. No typed local adapter contracts for Claude/Codex session and usage extraction.
7. No prompt-template variable/pill system in agent setup.
8. No deployment-aware adapter for full run log storage (disk/object store/etc).
## 5. Architecture Overview
The subsystem introduces six cooperating components:
1. `Adapter Registry`
- Maps `adapter_type` to implementation.
- Exposes capability metadata and config validation.
2. `Wakeup Coordinator`
- Single entrypoint for all wakeups (`timer`, `assignment`, `on_demand`, `automation`).
- Applies dedupe/coalescing and queue rules.
3. `Run Executor`
- Claims queued wakeups.
- Creates `heartbeat_runs`.
- Spawns/monitors child processes for local adapters.
- Handles timeout/cancel/graceful kill.
4. `Runtime State Store`
- Persists resumable adapter state per agent.
- Persists run usage summaries and lightweight run-event timeline.
5. `Run Log Store`
- Persists full stdout/stderr streams via pluggable storage adapter.
- Returns stable `logRef` for retrieval (local path, object key, or DB reference).
6. `Realtime Event Hub`
- Publishes run/agent/task updates over websocket.
- Supports selective subscription by company.
Control flow (happy path):
1. Trigger arrives (`timer`, `assignment`, `on_demand`, or `automation`).
2. Wakeup coordinator enqueues/merges wake request.
3. Executor claims request, creates run row, marks agent `running`.
4. Adapter executes, emits status/log/usage events.
5. Full logs stream to `RunLogStore`; metadata/events are persisted to DB and pushed to websocket subscribers.
6. Process exits, output parser updates run result + runtime state.
7. Agent returns to `idle` or `error`; UI updates in real time.
## 6. Agent Run Protocol (Version `agent-run/v1`)
This protocol is runtime-agnostic and implemented by all adapters.
```ts
type RunOutcome = "succeeded" | "failed" | "cancelled" | "timed_out";
type StatusColor = "neutral" | "blue" | "green" | "yellow" | "red";
interface TokenUsage {
inputTokens: number;
outputTokens: number;
cachedInputTokens?: number;
cachedOutputTokens?: number;
}
interface AdapterInvokeInput {
protocolVersion: "agent-run/v1";
companyId: string;
agentId: string;
runId: string;
wakeupSource: "timer" | "assignment" | "on_demand" | "automation";
triggerDetail?: "manual" | "ping" | "callback" | "system";
cwd: string;
prompt: string;
adapterConfig: Record<string, unknown>;
runtimeState: Record<string, unknown>;
env: Record<string, string>;
timeoutSec: number;
}
interface AdapterHooks {
status?: (update: { message: string; color?: StatusColor }) => Promise<void>;
log?: (event: { stream: "stdout" | "stderr" | "system"; chunk: string }) => Promise<void>;
usage?: (usage: TokenUsage) => Promise<void>;
event?: (eventType: string, payload: Record<string, unknown>) => Promise<void>;
}
interface AdapterInvokeResult {
outcome: RunOutcome;
exitCode: number | null;
errorMessage?: string | null;
summary?: string | null;
sessionId?: string | null;
usage?: TokenUsage | null;
provider?: string | null;
model?: string | null;
costUsd?: number | null;
runtimeStatePatch?: Record<string, unknown>;
rawResult?: Record<string, unknown> | null;
}
interface AgentRunAdapter {
type: string;
protocolVersion: "agent-run/v1";
capabilities: {
resumableSession: boolean;
statusUpdates: boolean;
logStreaming: boolean;
tokenUsage: boolean;
};
validateConfig(config: unknown): { ok: true } | { ok: false; errors: string[] };
invoke(input: AdapterInvokeInput, hooks: AdapterHooks, signal: AbortSignal): Promise<AdapterInvokeResult>;
}
```
### 6.1 Required Behavior
1. `validateConfig` runs before saving or invoking.
2. `invoke` must be deterministic for a given config + runtime state + prompt.
3. Adapter must not mutate DB directly; it returns data via result/events only.
4. Adapter must emit enough context for errors to be debuggable.
5. If `invoke` throws, executor records run as `failed` with captured error text.
### 6.2 Optional Behavior
Adapters may omit status/log hooks. If omitted, runtime still emits system lifecycle statuses (`queued`, `running`, `finished`).
### 6.3 Run log storage protocol
Full run logs are managed by a separate pluggable store (not by the agent adapter).
```ts
type RunLogStoreType = "local_file" | "object_store" | "postgres";
interface RunLogHandle {
store: RunLogStoreType;
logRef: string; // opaque provider reference (path, key, uri, row id)
}
interface RunLogStore {
begin(input: { companyId: string; agentId: string; runId: string }): Promise<RunLogHandle>;
append(
handle: RunLogHandle,
event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string },
): Promise<void>;
finalize(
handle: RunLogHandle,
summary: { bytes: number; sha256?: string; compressed: boolean },
): Promise<void>;
read(
handle: RunLogHandle,
opts?: { offset?: number; limitBytes?: number },
): Promise<{ content: string; nextOffset?: number }>;
delete?(handle: RunLogHandle): Promise<void>;
}
```
V1 deployment defaults:
1. Dev/local default: `local_file` (write to `data/run-logs/...`).
2. Cloud/serverless default: `object_store` (S3/R2/GCS compatible).
3. Optional fallback: `postgres` with strict size caps.
### 6.4 Adapter identity and compatibility
For V1 rollout, adapter identity is explicit:
- `claude_local`
- `codex_local`
- `process` (generic existing behavior)
- `http` (generic existing behavior)
`claude_local` and `codex_local` are not wrappers around arbitrary `process`; they are typed adapters with known parser/resume semantics.
## 7. Built-in Adapters (Phase 1)
## 7.1 `claude-local`
Runs local `claude` CLI directly.
### Config
```json
{
"cwd": "/absolute/or/relative/path",
"promptTemplate": "You are agent {{agent.id}} ...",
"model": "optional-model-id",
"maxTurnsPerRun": 1000,
"dangerouslySkipPermissions": true,
"filesystemScope": "workspace",
"filesystemExtraPaths": [
"/opt/toolchains",
{"path": "/var/cache/pnpm", "access": "rw"}
],
"networkScope": "allowlist",
"networkAllowlist": ["api.anthropic.com"],
"env": {"KEY": "VALUE"},
"extraArgs": [],
"timeoutSec": 1800,
"graceSec": 20
}
```
### Invocation
- Base command: `claude --print <prompt> --output-format json`
- Resume: add `--resume <sessionId>` when runtime state has session ID
- Unsandboxed mode: add `--dangerously-skip-permissions` when enabled
### Output parsing
1. Parse stdout JSON object.
2. Extract `session_id` for resume.
3. Extract usage fields:
- `usage.input_tokens`
- `usage.cache_read_input_tokens` (if present)
- `usage.output_tokens`
4. Extract `total_cost_usd` when present.
5. On non-zero exit: still attempt parse; if parse succeeds keep extracted state and mark run failed unless adapter explicitly reports success.
## 7.2 `codex-local`
Runs local `codex` CLI directly.
### Config
```json
{
"cwd": "/absolute/or/relative/path",
"promptTemplate": "You are agent {{agent.id}} ...",
"model": "optional-model-id",
"search": false,
"dangerouslyBypassApprovalsAndSandbox": true,
"filesystemScope": "workspace",
"filesystemExtraPaths": [
"/opt/toolchains",
{"path": "/var/cache/pnpm", "access": "rw"}
],
"networkScope": "allowlist",
"networkAllowlist": ["api.openai.com"],
"env": {"KEY": "VALUE"},
"extraArgs": [],
"timeoutSec": 1800,
"graceSec": 20
}
```
### Invocation
- Base command: `codex exec --json <prompt>`
- Resume form: `codex exec --json resume <sessionId> <prompt>`
- Unsandboxed mode: add `--dangerously-bypass-approvals-and-sandbox` when enabled
- Optional search mode: add `--search`
For Linux local coding adapters, `filesystemScope: "workspace"` adds host-level filesystem confinement around the CLI process. It is disabled by default and independent of the CLI's own approval or sandbox flags. Paperclip uses Bubblewrap to expose the active workspace and adapter-managed config/home, creates a private `/tmp`, and hides other host paths. `filesystemExtraPaths` can expose additional absolute paths read-only (string or `access: "ro"`) or writable (`access: "rw"`).
`networkScope` is also disabled by default and can be enabled independently or together with filesystem confinement. `"deny"` creates a private network namespace with no egress. `"allowlist"` keeps direct sockets blocked and injects an HTTP(S) proxy that accepts only exact `networkAllowlist` hostnames (optionally with a port); list the coding provider endpoint and every other required API origin explicitly. For example, a standard Codex API-key setup normally needs `api.openai.com`, while Claude setups may need `api.anthropic.com` or their configured Bedrock, Vertex, or gateway origins. Wildcards are intentionally unsupported. Install `bwrap` on the Paperclip host before enabling either scope. Auto engine selection uses the CLI lane while a scope is enabled; explicit ACP mode is rejected because ACP processes are not yet covered by the spawn wrapper.
### Output parsing
Codex emits JSONL events. Parse line-by-line and extract:
1. `thread.started.thread_id` -> session ID
2. `item.completed` where item type is `agent_message` -> output text
3. `turn.completed.usage`:
- `input_tokens`
- `cached_input_tokens`
- `output_tokens`
Codex JSONL currently may not include cost; store token usage as per-run totals and mark the ledger row `unpriced` unless a cost is available.
## 7.3 Common local adapter process handling
Both local adapters must:
1. Use `spawn(command, args, { shell: false, stdio: "pipe" })`.
2. Capture stdout/stderr in stream chunks and forward to `RunLogStore`.
3. Maintain rolling stdout/stderr tail excerpts in memory for DB diagnostic fields.
4. Emit live log events to websocket subscribers (optional to throttle/chunk).
5. Support graceful cancel: `SIGTERM`, then `SIGKILL` after `graceSec`.
6. Enforce timeout using adapter `timeoutSec`.
7. Return exit code + parsed result + diagnostic stderr.
## 8. Heartbeat and Wakeup Coordinator
## 8.1 Wakeup sources
Supported sources:
1. `timer`: periodic heartbeat per agent.
2. `assignment`: issue assigned/reassigned to agent.
3. `on_demand`: explicit wake request path (board/manual click or API ping).
4. `automation`: non-interactive wake path (external callback or internal system automation).
## 8.2 Central API
All sources call one internal service:
```ts
enqueueWakeup({
companyId,
agentId,
source,
triggerDetail, // optional: manual|ping|callback|system
reason,
payload,
requestedBy,
idempotencyKey?
})
```
No source invokes adapters directly.
## 8.3 Queue semantics
1. Max active run per agent remains `1`.
2. If agent already has `queued`/`running` run:
- coalesce duplicate wakeups
- increment `coalescedCount`
- preserve latest reason/source metadata
3. Queue is DB-backed for restart safety.
4. Coordinator uses FIFO by `requested_at`, with optional priority:
- `on_demand` > `assignment` > `timer`/`automation`
## 8.4 Agent heartbeat policy fields
Agent-level control-plane settings (not adapter-specific):
```json
{
"heartbeat": {
"enabled": true,
"intervalSec": 300,
"wakeOnAssignment": true,
"wakeOnOnDemand": true,
"wakeOnAutomation": true,
"cooldownSec": 10
}
}
```
Defaults:
- `enabled: true`
- `intervalSec: null` (no timer until explicitly set) or product default `300` if desired globally
- `wakeOnAssignment: true`
- `wakeOnOnDemand: true`
- `wakeOnAutomation: true`
## 8.5 Trigger integration rules
1. Timer checks run on server worker interval and enqueue due agents.
2. Issue assignment mutation enqueues wakeup when assignee changes and target agent has `wakeOnAssignment=true`.
3. On-demand endpoint enqueues wakeup with `source=on_demand` and `triggerDetail=manual|ping` when `wakeOnOnDemand=true`.
4. Callback/system automations enqueue wakeup with `source=automation` and `triggerDetail=callback|system` when `wakeOnAutomation=true`.
5. Paused/terminated agents do not receive new wakeups.
6. Hard budget-stopped agents do not receive new wakeups.
## 9. Persistence Model
All tables remain company-scoped.
## 9.0 Changes to `agents`
1. Extend `adapter_type` domain to include `claude_local` and `codex_local` (alongside existing `process`, `http`).
2. Keep `adapter_config` as adapter-owned config (CLI flags, cwd, prompt templates, env overrides).
3. Add `runtime_config` jsonb for control-plane scheduling policy:
- heartbeat enable/interval
- wake-on-assignment
- wake-on-on-demand
- wake-on-automation
- cooldown
This separation keeps adapter config runtime-agnostic while allowing the heartbeat service to apply consistent scheduling logic.
## 9.1 New table: `agent_runtime_state`
One row per agent for aggregate runtime counters and legacy compatibility.
- `agent_id` uuid pk fk `agents.id`
- `company_id` uuid fk not null
- `adapter_type` text not null
- `session_id` text null
- `state_json` jsonb not null default `{}`
- `last_run_id` uuid fk `heartbeat_runs.id` null
- `last_run_status` text null
- `total_input_tokens` bigint not null default `0`
- `total_output_tokens` bigint not null default `0`
- `total_cached_input_tokens` bigint not null default `0`
- `total_cost_cents` bigint not null default `0`
- `last_error` text null
- `updated_at` timestamptz not null
Invariant: exactly one runtime state row per agent.
## 9.1.1 New table: `agent_task_sessions`
One row per `(company_id, agent_id, adapter_type, task_key)` for resumable session state.
- `id` uuid pk
- `company_id` uuid fk not null
- `agent_id` uuid fk not null
- `adapter_type` text not null
- `task_key` text not null
- `session_params_json` jsonb null (adapter-defined shape)
- `session_display_id` text null (for UI/debug)
- `last_run_id` uuid fk `heartbeat_runs.id` null
- `last_error` text null
- `created_at` timestamptz not null
- `updated_at` timestamptz not null
Invariant: unique `(company_id, agent_id, adapter_type, task_key)`.
## 9.2 New table: `agent_wakeup_requests`
Queue + audit for wakeups.
- `id` uuid pk
- `company_id` uuid fk not null
- `agent_id` uuid fk not null
- `source` text not null (`timer|assignment|on_demand|automation`)
- `trigger_detail` text null (`manual|ping|callback|system`)
- `reason` text null
- `payload` jsonb null
- `status` text not null (`queued|claimed|coalesced|skipped|completed|failed|cancelled`)
- `coalesced_count` int not null default `0`
- `requested_by_actor_type` text null (`user|agent|system`)
- `requested_by_actor_id` text null
- `idempotency_key` text null
- `run_id` uuid fk `heartbeat_runs.id` null
- `requested_at` timestamptz not null
- `claimed_at` timestamptz null
- `finished_at` timestamptz null
- `error` text null
## 9.3 New table: `heartbeat_run_events`
Append-only per-run lightweight event timeline (no full raw log chunks).
- `id` bigserial pk
- `company_id` uuid fk not null
- `run_id` uuid fk `heartbeat_runs.id` not null
- `agent_id` uuid fk `agents.id` not null
- `seq` int not null
- `event_type` text not null (`lifecycle|status|usage|error|structured`)
- `stream` text null (`system|stdout|stderr`) (summarized events only, not full stream chunks)
- `level` text null (`info|warn|error`)
- `color` text null
- `message` text null
- `payload` jsonb null
- `created_at` timestamptz not null
## 9.4 Changes to `heartbeat_runs`
Add fields required for result and diagnostics:
- `wakeup_request_id` uuid fk `agent_wakeup_requests.id` null
- `exit_code` int null
- `signal` text null
- `usage_json` jsonb null
- `result_json` jsonb null
- `session_id_before` text null
- `session_id_after` text null
- `log_store` text null (`local_file|object_store|postgres`)
- `log_ref` text null (opaque provider reference; path/key/uri/row id)
- `log_bytes` bigint null
- `log_sha256` text null
- `log_compressed` boolean not null default false
- `stderr_excerpt` text null
- `stdout_excerpt` text null
- `error_code` text null
This keeps per-run diagnostics queryable without storing full logs in Postgres.
## 9.5 Log storage adapter configuration
Runtime log storage is deployment-configured (not per-agent by default).
```json
{
"runLogStore": {
"type": "local_file | object_store | postgres",
"basePath": "./data/run-logs",
"bucket": "paperclip-run-logs",
"prefix": "runs/",
"compress": true,
"maxInlineExcerptBytes": 32768
}
}
```
Rules:
1. `log_ref` must be opaque and provider-neutral at API boundaries.
2. UI/API must not assume local filesystem semantics.
3. Provider-specific secrets/credentials stay in server config, never in agent config.
## 10. Prompt Template and Pill System
## 10.1 Template format
- Mustache-style placeholders: `{{path.to.value}}`
- No arbitrary code execution.
- Unknown variable on save = validation error.
## 10.2 Initial variable catalog
- `company.id`
- `company.name`
- `agent.id`
- `agent.name`
- `agent.role`
- `agent.title`
- `run.id`
- `run.source`
- `run.startedAt`
- `heartbeat.reason`
- `paperclip.skill` (shared Paperclip skill text block)
- `credentials.apiBaseUrl`
- `credentials.apiKey` (optional, sensitive)
## 10.3 Prompt fields
1. `promptTemplate`
- Used on every wakeup (first run and resumed runs).
- Can include run source/reason pills.
## 10.4 UI requirements
1. Agent setup/edit form includes prompt editors with pill insertion.
2. Variables are shown as clickable pills for fast insertion.
3. Save-time validation indicates unknown/missing variables.
4. Sensitive pills (`credentials.*`) show explicit warning badge.
## 10.5 Security notes for credentials
1. Credentials in prompt are allowed for initial simplicity but discouraged.
2. Preferred transport is env vars (`PAPERCLIP_*`) injected at runtime.
3. Prompt preview and logs must redact sensitive values.
## 11. Realtime Status Delivery
## 11.1 Transport
Primary transport: websocket channel per company.
- Endpoint: `GET /api/companies/:companyId/events/ws`
- Auth: board session or agent API key (company-bound)
## 11.2 Event envelope
```json
{
"eventId": "uuid-or-monotonic-id",
"companyId": "uuid",
"type": "heartbeat.run.status",
"entityType": "heartbeat_run",
"entityId": "uuid",
"occurredAt": "2026-02-17T12:00:00Z",
"payload": {}
}
```
## 11.3 Required event types
1. `agent.status.changed`
2. `heartbeat.run.queued`
3. `heartbeat.run.started`
4. `heartbeat.run.status` (short color+message updates)
5. `heartbeat.run.log` (optional live chunk stream; full persistence handled by `RunLogStore`)
6. `heartbeat.run.finished`
7. `issue.updated`
8. `issue.comment.created`
9. `activity.appended`
## 11.4 UI behavior
1. Agent detail view updates run timeline live.
2. Task board reflects assignment/status/comment changes from agent activity without refresh.
3. Org/agent list reflects status changes live.
4. If websocket disconnects, client falls back to short polling until reconnect.
## 12. Error Handling and Diagnostics
## 12.1 Error classes
- `adapter_not_installed`
- `invalid_working_directory`
- `spawn_failed`
- `timeout`
- `cancelled`
- `nonzero_exit`
- `output_parse_error`
- `resume_session_invalid`
- `budget_blocked`
## 12.2 Logging requirements
1. Persist full stdout/stderr stream to configured `RunLogStore`.
2. Persist only lightweight run metadata/events in Postgres (`heartbeat_runs`, `heartbeat_run_events`).
3. Persist bounded `stdout_excerpt` and `stderr_excerpt` in Postgres for quick diagnostics.
4. Mark truncation explicitly when excerpts are capped.
5. Redact secrets from logs, excerpts, and websocket payloads.
## 12.3 Log retention and lifecycle
1. `RunLogStore` retention is configurable by deployment (for example 7/30/90 days).
2. Postgres run metadata can outlive full log objects.
3. Deletion/pruning jobs must handle orphaned metadata/log-object references safely.
4. If full log object is gone, APIs still return metadata and excerpts with `log_unavailable` status.
## 12.4 Restart recovery
On server startup:
1. Find stale `queued`/`running` runs.
2. Mark as `failed` with `error_code=control_plane_restart`.
3. Set affected non-paused/non-terminated agents to `error` (or `idle` based on policy).
4. Emit recovery events to websocket and activity log.
## 13. API Surface Changes
## 13.1 New/updated endpoints
1. `POST /agents/:agentId/wakeup`
- enqueue wakeup with source/reason
2. `POST /agents/:agentId/heartbeat/invoke`
- backward-compatible alias to wakeup API
3. `GET /agents/:agentId/runtime-state`
- board-only debug view
4. `GET /agents/:agentId/task-sessions`
- board-only list of task-scoped adapter sessions
5. `POST /agents/:agentId/runtime-state/reset-session`
- clears all task sessions for the agent, or one when `taskKey` is provided
6. `GET /heartbeat-runs/:runId/events?afterSeq=:n`
- fetch persisted lightweight timeline
7. `GET /heartbeat-runs/:runId/log`
- reads full log stream via `RunLogStore` (or redirects/presigned URL for object store)
8. `GET /api/companies/:companyId/events/ws`
- websocket stream
## 13.2 Mutation logging
All wakeup/run state mutations must create `activity_log` entries:
- `wakeup.requested`
- `wakeup.coalesced`
- `heartbeat.started`
- `heartbeat.finished`
- `heartbeat.failed`
- `heartbeat.cancelled`
- `runtime_state.updated`
## 14. Heartbeat Service Implementation Plan
## Phase 1: Contracts and schema
1. Add new DB tables/columns (`agent_runtime_state`, `agent_wakeup_requests`, `heartbeat_run_events`, `heartbeat_runs.log_*` fields).
2. Add `RunLogStore` interface and configuration wiring.
3. Add shared types/constants/validators.
4. Keep existing routes functional during migration.
## Phase 2: Wakeup coordinator
1. Implement DB-backed wakeup queue.
2. Convert invoke/wake routes to enqueue with `source=on_demand` and appropriate `triggerDetail`.
3. Add worker loop to claim and execute queued wakeups.
## Phase 3: Local adapters
1. Implement `claude-local` adapter.
2. Implement `codex-local` adapter.
3. Parse and persist session IDs and token usage.
4. Wire cancel/timeout/grace behavior.
## Phase 4: Realtime push
1. Implement company websocket hub.
2. Publish run/agent/issue events.
3. Update UI pages to subscribe and invalidate/update relevant data.
## Phase 5: Prompt pills and config UX
1. Add adapter-specific config editor with prompt templates.
2. Add pill insertion and variable validation.
3. Add sensitive-variable warnings and redaction.
## Phase 6: Hardening
1. Add failure/restart recovery sweeps.
2. Add metadata/full-log retention policies and pruning jobs.
3. Add integration/e2e coverage for wakeup triggers and live updates.
## 15. Acceptance Criteria
1. Agent with `claude-local` or `codex-local` can run, exit, and persist run result.
2. Session parameters are persisted per task scope and reused automatically for same-task resumes.
3. Token usage is persisted per run and accumulated per agent runtime state.
4. Timer, assignment, on-demand, and automation wakeups all enqueue through one coordinator.
5. Pause/terminate interrupts running local process and prevents new wakeups.
6. Browser receives live websocket updates for run status/logs and task/agent changes.
7. Failed runs expose rich CLI diagnostics in UI with excerpts immediately available and full log retrievable via `RunLogStore`.
8. All actions remain company-scoped and auditable.
## 16. Open Questions
1. Should timer default be `null` (off until enabled) or `300` seconds by default?
2. What should the default retention policy be for full log objects vs Postgres metadata?
3. Should agent API credentials be allowed in prompt templates by default, or require explicit opt-in toggle?
4. Should websocket be the only realtime channel, or should we also expose SSE for simpler clients?