## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Local agent adapters (`claude_local`, `gemini_local`, `grok_local`, …) are the integration surface that lets Paperclip run coding CLIs on the host machine > - The Kimi Code CLI (`kimi`, Moonshot AI) has a documented non-interactive mode, `kimi -p --output-format stream-json` with session resume via `kimi -r`, but Paperclip has no built-in adapter for it > - So Kimi users (especially Kimi membership / OAuth subscribers) cannot onboard their CLI to Paperclip agent teams > - This pull request adds a complete built-in `kimi_local` adapter (both execution engines, session management, instructions + skills delivery, thinking-effort control, environment test, UI and CLI modules, docs) following the established `gemini_local`/`grok_local` package pattern > - Kimi Code ships an ACP server (`kimi acp`), so the adapter runs on Paperclip's shared acpx engine by default (streaming transcript with live tool status, like `claude_local`/`gemini_local`) and falls back to a headless CLI lane (`kimi -p --output-format stream-json`) when ACP prerequisites are unavailable > - The benefit is that Kimi Code becomes a first-class Paperclip agent lane: selectable in the UI, resumable across heartbeats, with the same operating context (instruction bundle, skills, effort) and streaming transcript the other local adapters get ## Linked Issues or Issue Description - Supersedes #9880 (same branch; expanded from the CLI-only lane into a complete adapter with the default ACP engine lane, control-plane skill install, and live transcript wiring) - Refs #9879 (adapter request for Kimi Code CLI, filed with this PR) - Refs #163 (original Kimi support request) Duplicate/related prior PRs, per the dedup search (both appear stale: no updates or maintainer review since May 2026, and both target an older Kimi CLI interface; calling them out for reviewer context per CONTRIBUTING.md): - Refs #6276 (`feat: add kimi-local adapter`): targets an older array-based content format (`{type: think}`/`{type: text}` blocks), not the current documented stream-json schema - Refs #5202 (`feat(adapter): add Kimi CLI local adapter with Wire protocol support`): builds on a `--wire` JSON-RPC interface that current Kimi Code CLI (0.27.0) no longer documents; the current documented headless interface is `-p --output-format stream-json` This PR is a fresh implementation against current master and the currently documented/verified Kimi CLI behavior (see Verification). Happy to fold in anything useful from the earlier attempts if a reviewer prefers. ## What Changed - **New adapter package** `packages/adapters/kimi-local` (`@paperclipai/adapter-kimi-local`), modeled on `gemini-local`/`grok-local`: - `src/server/execute.ts`: spawns `kimi -p <prompt> --output-format stream-json` (argv array, no shell), `-m <model>` only when configured, `-r <sessionId>` when the stored session cwd matches the run cwd, automatic fresh-session retry on unrecoverable-session errors, headless-safe env (`CI=1`, `NO_COLOR=1`, `KIMI_CODE_NO_AUTO_UPDATE=1`, `TERM=dumb`; user-configured values win), full remote (ssh/sandbox) execution lane with runtime install via `@moonshot-ai/kimi-code` - **Instruction bundle delivery**: the prompt path directive now names the sibling instruction files (`./HEARTBEAT.md`, `./SOUL.md`, `./TOOLS.md`) alongside the prepended entry file, and local runs pass `--add-dir <instructions-dir>` so Kimi can actually open them (matching `claude_local`). Without this, only the entry file reached Kimi and agents improvised the operating workflow that `HEARTBEAT.md` documents - **Thinking effort**: a configured `effort` is forwarded as the `KIMI_MODEL_THINKING_EFFORT` operational override (Kimi has no per-invocation effort flag). It is only sent for models that advertise `support_efforts` (currently `kimi-code/k3`) to avoid provider rejections, and `medium` maps to `high` since Kimi has no medium tier (`low`/`high`/`max` pass through) - **Skills delivery**: desired Paperclip skills are delivered via Kimi's `--skills-dir` flag from a dedicated per-run directory (a local snapshot, or the synced snapshot on remote targets), so skills load reliably and in isolation. Paperclip never overwrites the shared `$KIMI_CODE_HOME/skills` home, so skills installed by the operator or other agents are left intact. `--skills-dir` is only passed when at least one skill is desired, so unconfigured agents keep Kimi's default skill discovery - **Live run status**: the adapter now forwards each streamed stream-json line to `onEvent` (assistant `content` as an assistant snippet, `tool_calls` as tool-name events), which drives the issue-thread activity indicator (`currentToolName` / `lastAssistantSnippet` / `lastEventAt`). Previously the adapter only wrote the raw run log, so the issue thread showed a stale "no output for N s" line with no tool or reasoning context while Kimi worked. Tool results are omitted so the last meaningful "Using X" / snippet is not overwritten by a generic label - `src/server/parse.ts`: parses the verified Kimi stream-json event shapes (`assistant` text, `assistant.tool_calls` with JSON-string arguments, `tool` results, trailing `meta.session.resume_hint` for session-id capture) plus failure classifiers (`kimi_auth_required`, transient network, unrecoverable session). A signaled exit (null exit code, not a timeout) is now reported as a failure rather than coalesced to success, and the error message names the terminating signal - `src/server/skills.ts`: lists/syncs Paperclip skills for the adapter's skill-management surface - `src/server/test.ts`: environment test covering CLI resolution + `kimi --version`, cwd check, auth detection (OAuth credential dirs, keyed `[providers.*]` in config.toml, or the `KIMI_MODEL_NAME` + `KIMI_MODEL_API_KEY` env pair), and a live hello probe - `src/ui/` (stdout-line parser for transcripts, config builder) and `src/cli/` (stream event formatter) modules - Root metadata: three managed model aliases (`kimi-code/kimi-for-coding`, `kimi-code/kimi-for-coding-highspeed`, `kimi-code/k3`), effort-capable-model metadata (`EFFORT_CAPABLE_MODELS`, effort mapping helpers), `agentConfigurationDoc` - Tests: 101 tests across parse, execute (args building, resume gating, retry, auth error code, timeout, signaled-exit failure, effort forwarding/gating/mapping, `--add-dir` instructions directive, `--skills-dir` gating, `onEvent` runtime-event forwarding), ACP engine (engine resolution, acpx config build, node-version gate), ACP transcript delegation, environment test, UI parse/build-config - **ACP engine lane (default)** (`src/server/acp.ts` + shared `adapter-utils/acpx-engine`): Kimi Code ships an ACP server (`kimi acp`), so `kimi_local` now runs on Paperclip's shared acpx engine by default, matching `claude_local`/`codex_local`/`gemini_local`. The issue-thread transcript streams live (assistant text deltas, tool calls with a `pending`->`completed` status lifecycle) instead of the CLI lane's bursty complete-message output. Registered `kimi_local -> "kimi"` in `ACPX_ADAPTER_AGENT_IDS` and resolved the built-in agent command to `kimi acp`; `execute.ts` dispatches to the ACP executor first with an automatic CLI fallback when ACP prerequisites fail (`engine=acp` requires ACP, `engine=cli` pins the headless lane); `index.ts` falls back to the shared acpx session codec; the UI/CLI delegate `acpx.*` events to the shared acpx transcript parser and event formatter. The headless CLI lane (above) remains as the fallback - **Registration** (one entry each, mirroring existing adapters): server adapter registry + `BUILTIN_ADAPTER_TYPES`, `AGENT_ADAPTER_TYPES` (shared), UI adapter registry + display registry (`Kimi Code`, Moon icon) + capabilities defaults, CLI adapter registry, `Dockerfile` (package copy + `npm install --global @moonshot-ai/kimi-code@latest`), `vitest.config.ts` workspace, `scripts/release-package-manifest.json` - **Behavioral sets** mirroring `gemini_local` (Kimi resumes sessions the same way): `GIT_SENSITIVE_LOCAL_ADAPTER_TYPES`, `SESSIONED_LOCAL_ADAPTERS` (heartbeat + recovery), `REMOTE_MANAGED_ADAPTERS`, ssh/sandbox execution-target allow-lists, `ADAPTER_DEFAULT_RULES_BY_TYPE` (`timeoutSec: 0`, `graceSec: 15`), and `LEGACY_SESSIONED_ADAPTER_TYPES` + `ADAPTER_SESSION_MANAGEMENT` in adapter-utils - **UI touch-points**: New Agent default-model branch, AgentConfigForm command map (`kimi_local: "kimi"`) + model defaults + a Kimi-specific thinking-effort option list (`Low`/`High`/`Max`, reflecting Kimi's tiers rather than borrowing Claude's), OnboardingWizard (command map, model default, `kimi login` / `KIMI_MODEL_NAME + KIMI_MODEL_API_KEY` auth hints, manual-debug command line), InviteLanding enabled adapters - **Control-plane skill install** (`cli/src/commands/client/agent.ts`): `paperclipai agent local-cli` seeded the Paperclip control-plane skills into `~/.codex/skills` and `~/.claude/skills` so Codex/Claude agents auto-discover the API reference every run. Kimi had no equivalent target, so `kimi_local` agents began each session without the control-plane skill and rediscovered routes (e.g. the company-scoped `POST /api/companies/{companyId}/issues`) by trial and error. Added `~/.kimi-code/skills` (honoring `KIMI_CODE_HOME`) as a third install target for parity. Independent of the per-run `--skills-dir` delivery, which only applies to explicitly configured skills. - **Docs**: `docs/adapters/kimi-local.md` (prerequisites, auth options, config fields including `effort`, session resume, instruction bundle, skills delivery, control-plane skill install) + a row in `docs/adapters/overview.md` Out of scope (deliberately): model profiles, built-in agent `allowedAdapterTypes` additions. ## Verification\n\nCurrent-master rebase verification (OpenAI Codex, 2026-08-03): 13 focused files / 231 tests pass; adapter-utils, server, UI, CLI, and Kimi adapter typechecks pass; full repository build and UI token gates pass. The branch is conflict-free against master at head `1249df117c5e12e5771b9a570a6340866450619e`.\n\nAutomated (all from repo root, pnpm 9.15.4, Node 22): - `vitest run packages/adapters/kimi-local`: 89/89 pass (includes coverage for the instruction `--add-dir` directive, effort forwarding/gating/mapping, `--skills-dir` gating, the signaled-exit failure path, and `onEvent` runtime-event forwarding with cross-chunk line buffering) - `vitest run server/src/__tests__/adapter-registry.test.ts server/src/__tests__/adapter-routes.test.ts server/src/services/heartbeat-stop-metadata.test.ts ui/src/adapters/adapter-display-registry.test.ts`: 37/37 pass - `vitest run cli/src/__tests__/skills.test.ts`: 13/13 pass (the control-plane skill install target follows the existing Codex/Claude install path, whose symlink logic is unchanged) - `vitest run packages/shared`: 307/307 pass; `vitest run packages/adapter-utils`: pass except one pre-existing, unrelated failure (`mcp-isolation.integration.test.ts` requires Claude CLI ≥ 2.1.207; host has 2.1.185, fails identically on unmodified master) - `pnpm --filter @paperclipai/adapter-kimi-local typecheck|build`, plus typecheck of `server`, `ui`, `cli`, `adapter-utils`: all clean - `pnpm install --frozen-lockfile`: passes (the PR diff itself contains no lockfile changes, per repo policy; verified against a locally regenerated lockfile) - `node scripts/check-no-git-push.mjs` and `node scripts/check-forbidden-tokens.mjs`: pass - CI note: the `policy` job's release-bootstrap step is expected to stay red until a maintainer bootstraps the first npm publish of `@paperclipai/adapter-kimi-local`; see the CI Note for Maintainers comment. All other contributor-actionable checks are green. Manual end-to-end (real Kimi CLI 0.27.0, OAuth login, dev server on an isolated instance): 1. Server `GET /api/adapters` lists `kimi_local` as builtin with correct capability flags; models endpoint returns the three Kimi models 2. `POST .../adapters/kimi_local/test-environment`: all checks pass, including a live `kimi -p` hello probe 3. Created a `kimi_local` agent and invoked two heartbeats: run 1 spawned `kimi -p ... --output-format stream-json`, Kimi used its `Read` tool, produced the expected answer, and the session id was captured from the `session.resume_hint` meta event; run 2 resumed the **same** Kimi session (`sessionIdBefore == sessionIdAfter`) via `-r` 4. UI: adapter appears in the New Agent dropdown; selecting it shows the Kimi command placeholder, the three models, and the Kimi config fields; the run transcript renders Kimi tool calls via the adapter's stdout parser The instruction-bundle, thinking-effort, and `--skills-dir` changes landed after the manual run above. They are covered by the unit tests listed under Automated, and the Kimi CLI flags they rely on (`--add-dir`, `--skills-dir`, `KIMI_MODEL_THINKING_EFFORT`) were confirmed against the installed Kimi Code CLI 0.27.0 (`kimi --help`, config-file thinking-effort docs). Screenshots (assets branch on the fork, not part of the diff):       ## Risks - Low risk to existing behavior: the change is additive, one new workspace package plus single-entry registrations alongside existing adapters; no existing adapter code paths are modified. - The adapter invokes the locally installed `kimi` CLI; like other local adapters, run behavior depends on the host's Kimi version. The parser is written against the documented/verified 0.27.0 stream-json schema and degrades gracefully (malformed lines are skipped, failures surface as run errors). - `--skills-dir` overrides Kimi's auto-discovery of user and project skills for the run. This is intentional (paperclip-managed agents get a reproducible, isolated skill set), and it is only passed when at least one Paperclip skill is desired, so unconfigured agents keep default discovery. - Thinking effort is only forwarded to models that advertise `support_efforts` (currently `kimi-code/k3`); `EFFORT_CAPABLE_MODELS` must be extended when more Kimi models gain support, otherwise a configured effort is silently ignored for them. - `Dockerfile` now installs `@moonshot-ai/kimi-code@latest` globally alongside the other agent CLIs, so image size increases slightly. - Maintainer action needed for the npm bootstrap gate: the `policy` job's release-bootstrap step fails until the first npm publish of `@paperclipai/adapter-kimi-local` (the gate from #5146 that every new adapter package has passed through). Enrollment with `publishFromCi: true` is required by the manifest validator (dropping the entry, `false`, or `private` are all rejected), so this is intentionally left to a maintainer. Remaining CI lanes are expected to run once it is done. ## Model Used\n\n- **Current-master rebase, conflict adaptation, and registry-parity coverage:** OpenAI, **GPT-5 Codex** (Codex agent; exact serving model ID and context-window size were not exposed to the runtime), with repository, shell, Git, and GitHub tooling. It preserved Hawik’s commit authorship, reconciled ACPX and environment-capability changes, added current registry tests, and ran the verification above.\n- **Adapter implementation and initial review:** Moonshot AI, **Kimi K3 Coding** (latest), via **Kimi Code CLI v0.27.0** (`kimi-code/k3` alias, 1M-token context window, thinking mode, agentic tool use). The CLI agent explored the repo, wrote the adapter implementation (delegated to a coder sub-agent of the same model), ran tests, and drafted the first version of this PR body. A second model-driven review pass (read-only, same model) audited the diff for security/correctness before submission; its findings (shell-quoting hardening, auth-detection false positive, session-compaction registration, test gaps) were fixed and are included. - **Harness-context fixes and review responses:** Anthropic, **Claude Opus 4.8** (`claude-opus-4-8`) via Claude Code. Diagnosed from run logs that Kimi received only the entry instructions file (not the `HEARTBEAT.md`/`SOUL.md`/`TOOLS.md` bundle) and that `effort` was never wired, then implemented the instruction `--add-dir` delivery, `KIMI_MODEL_THINKING_EFFORT` forwarding, and `--skills-dir` skill delivery, added the accompanying tests and docs, and addressed the automated review comments (preserving external skills on remote sync, treating a signaled exit as a failure). Also extended the `paperclipai agent local-cli` installer to seed the control-plane skills into `~/.kimi-code/skills` for Codex/Claude parity, wired `onEvent` runtime events so the issue-thread activity indicator reflects Kimi's tool and reasoning output live, and built the ACP engine lane (`kimi acp` via the shared acpx engine, default) so the transcript streams with live tool status like the other ACP adapters. The Kimi CLI flags, subcommand, and env var relied on here were verified against the installed Kimi Code CLI 0.27.0. - All CLI behaviors claimed here (`-p`, `--output-format stream-json`, `-r` resume, event shapes, `--add-dir`, `--skills-dir`, `KIMI_MODEL_THINKING_EFFORT`) were verified empirically against the installed Kimi CLI, not assumed. ## 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 - [x] All Paperclip CI gates are green *(only the release-bootstrap step remains red, pending the maintainer npm publish described in Risks)* - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups *(will address all Greptile comments as they arrive)* - [x] I will address all Greptile and reviewer comments before requesting merge --- ## Maintainer Addendum (2026-08-20) The shared acpx-engine and issue-chat changes (run-summary segmentation, placeholder tool-event coalescing, `ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES` 30 → 400, live-reasoning UI) have been **extracted to #11761** so the cross-adapter behavior changes review and revert independently — both commits there preserve @hawikk's authorship. This PR is now the kimi-specific adapter only (60 files, +3,793/−8, essentially pure addition); the only shared-engine touch left is the `kimi acp` command resolution. `publishFromCi` is `true` — the package name is bootstrapped on npm. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Dotta <bippadotta@protonmail.com> Co-authored-by: Devin Foley <devin@paperclip.ing>
Telemetry Data Contract
This document explains how contributors should use Paperclip's public telemetry contract. It does not duplicate the full list of individual events or dimensions. It documents extra semantic and privacy rules where the generated shape is not sufficient.
The canonical source for first-party event names, dimensions, optionality,
allowed primitive value types, and enum descriptions is
packages/shared/src/telemetry/generated/paperclip-telemetry.ts.
Shared enum constants live in packages/shared/src/constants.ts. Use those
constants when code needs a reusable domain, but treat the generated telemetry
types as the final authority for emitted first-party telemetry shapes.
Public Sources
Use these files when reviewing or changing telemetry code:
| Contract item | Public source |
|---|---|
| First-party event names | PaperclipEventName in generated/paperclip-telemetry.ts |
| Per-event dimensions and optionality | EventDimensionsMap in generated/paperclip-telemetry.ts |
| Enum descriptions for telemetry dimensions | PAPERCLIP_ENUM_DESCRIPTIONS in generated/paperclip-telemetry.ts |
| Schema version and event envelope helpers | SCHEMA_VERSION, makeEvent(), and makeBatch() in generated/paperclip-telemetry.ts |
| Runtime-safe event names and dimensions | TelemetryEventName and TelemetryEventDimensions in types.ts |
| Allowed primitive dimension values | TelemetryDimensionValue in types.ts |
| Shared reusable enum domains | Named exports in constants.ts |
| First-party typed emit helpers | events.ts |
| Generic client behavior | client.ts |
| Retention windows and event class assignments | RETENTION_DAYS and EVENT_RETENTION_CLASS in retention.ts |
Do not copy generated event lists or dimension tables into this README. They will drift as the generated contract changes.
Emission Boundary
Paperclip telemetry uses named events with explicit dimension fields. Treat open-ended string dimensions as public contract values, not as a place for user content or private operational data. Do not send PII, secrets, credentials, private paths, prompts, model output, or other sensitive values through telemetry dimensions.
Telemetry emitters send raw dimension values. They must not pre-normalize enum-like values into a reporting form just to match today's known domain.
The receiving layer owns canonicalization. Keeping canonicalization in one place means emitters can stay simple and accurate: emit what the product observed, use the generated contract for required and optional fields, and let the receiving layer decide how legacy spellings, aliases, unknown names, and future values map to a stable reporting shape.
Do not add client-side lowercasing, alias mapping, or fallback mapping unless the generated telemetry contract specifically requires that emitted value.
If a dimension is privacy-protected before emission, emit only the protected value and its matching public marker as defined by the typed helper or generated contract. Do not emit private source material in telemetry dimensions.
Interaction Resolver Events
interaction.created records the interaction kind and whether the create
request used a deprecated resolver-policy alias. It does not record the prompt,
title, options, questions, target identifier, creator identifier, or resolver
identifier.
interaction.resolved records the low-cardinality interaction outcome defined
in the generated contract. Its legacy_inherited_restriction dimension is
true only when stored migration provenance preserves a legacy resolver-policy
restriction. It is false for canonical new writes. This dimension describes
policy provenance. It does not contain user content or an identifier.
Use trackInteractionCreated() and trackInteractionResolved() from
events.ts to emit these events. The generated contract remains the authority
for their exact dimensions and optionality.
Sandbox Startup Trace Spans
Paperclip opens OpenTelemetry spans on the sandbox start path. These spans are a separate telemetry surface from the first-party events above. The generated telemetry contract does not cover them, so this section is their canonical contract.
The spans are opt-in. Paperclip exports them only when an OTLP endpoint is configured. With no endpoint the whole span path is a no-op. Paperclip opens the spans only for a run that targets a remote sandbox. A local run and an SSH run stay out of these spans.
Every span attribute uses the closed paperclip.sandbox.startup. prefix and
rides a fixed allowlist. A command line, an argument, an environment value, a
file path, program output, or a raw identifier never rides a span. It rides
neither as an attribute nor as an event. The producer bounds each free-form
value:
- A command basename maps to a small known set. Any other value maps to
other. - A region maps to a small known set. Any other value maps to
unknown. - An image id, a sandbox id, and a lease id ride only as a non-reversible short hash.
Each numeric attribute is finite. Paperclip omits an attribute when its value is
absent, never a misleading 0.
Spans
| Span | Scope | Parent |
|---|---|---|
sandbox.startup |
The one root span for a sandbox bring-up. | none (root) |
workspace.resolve |
Workspace resolution step. | sandbox.startup |
codex-home.seed |
Managed-home seed step. | sandbox.startup |
skills.reconcile |
Skills reconcile step. | sandbox.startup |
stage.sync |
Workspace stage-sync step. | sandbox.startup |
snapshot.git |
Host-side git workspace enumeration inside stage.sync (git status --ignored, the HEAD diffs, ls-files). |
stage.sync |
snapshot.baseline |
Host-side baseline workspace content-hash walk inside stage.sync, kept for restore. |
stage.sync |
stage.workspace |
One inbound workspace stage task inside stage.sync. It packs and uploads the workspace. |
stage.sync |
stage.asset.<key> |
One inbound asset stage task inside stage.sync. It packs and uploads one managed-home asset. The <key> segment is the asset key. |
stage.sync |
stage.project.<id> |
One inbound referenced-project stage task inside stage.sync. It uploads one referenced project. The <id> segment is the project id. |
stage.sync |
pack |
Host-side workspace tarball build inside the stage.workspace task. |
stage.workspace |
bridge.paperclip |
Paperclip bridge start step. | sandbox.startup |
bridge.process-session |
Process-session bridge start step. | sandbox.startup |
acp.handshake |
ACP session handshake step. | sandbox.startup |
sandbox.syncBack |
The settlement sync-back that restores the managed home at teardown. | the active run span |
restore.workspace |
One outbound workspace restore task at teardown. It reads the sandbox workspace back and merges it into the host workspace. | sandbox.syncBack |
restore.asset.<key> |
One outbound asset restore task at teardown. It reads one asset back to its host store. The <key> segment is the asset key. |
sandbox.syncBack |
sandbox.agentSession.sendInput |
One outbound ACP message to the agent — the socket handler's one writeTextFile exec. |
the active run span |
sandbox.agentSession.pollOutput |
One 100 ms poll tick — list, then read+remove per file found (1 + 2n execs). |
the active run span |
sandbox.callbackBridge.relayRequest |
One Paperclip-API callback request — read the request, write the response, remove it. | the active run span |
sandbox.agentProcess |
The persistent streamed agent process the process-session bridge launches; open until the process settles or the bridge tears down, whichever comes first. | the active run span |
sandbox.exec |
One host-to-sandbox execution. | the active step or wrapper span |
A step span name is the step name. The sandbox.exec span parents to the step
span that runs the execution, so each execution nests under its step. Within
stage.sync, the host-side sub-steps snapshot.git and snapshot.baseline open
as child spans of the step, so the host work at the head of the step is
attributed rather than showing as a gap. Each inbound sync operation also opens
its own task span under stage.sync: stage.workspace, one stage.asset.<key>
per asset, and one stage.project.<id> per referenced project. The pack span
nests under stage.workspace, because the host builds the tarball inside that
task. Two concurrent tasks produce overlapping spans.
The settlement sandbox.syncBack span runs at teardown and parents to the run
span. It wraps the managed-home restore. Each outbound restore operation opens
its own task span under sandbox.syncBack: restore.workspace and one
restore.asset.<key> per asset. Two concurrent restore tasks produce overlapping
spans. A run-time
sandbox.exec span parents instead to the run-time wrapper span that runs it
(sandbox.agentSession.sendInput, sandbox.agentSession.pollOutput,
sandbox.callbackBridge.relayRequest, or sandbox.agentProcess). Each run-time
wrapper span parents to the live run span (agent.turn during the turn,
task.run otherwise). With no active trace context the exec span opens
unparented.
sandbox.agentProcess wraps the persistent streamed agent process. The
process-session bridge launches it during bridge.process-session, so it opens
under task.run — no turn has started yet. It therefore overlaps the sibling
agent.turn rather than nesting under it or dangling off the short-lived bring-up
step. The span ends when the process settles or when the bridge tears down,
whichever comes first. The bridge tears down before the run root span ends, so
the span never outlives task.run even when the process lingers past teardown
(the sandbox execute has no cancel, so a lingering process cannot be forced to
resolve).
The root span sets the error status when the bring-up fails. Each step span sets
the error status when its step fails. The sandbox.exec span sets the error
status when the exit code is non-zero or the execution throws.
Outcome values
The paperclip.sandbox.startup.outcome attribute uses a closed value set:
ok— the step or the execution settled with a success result.skipped— a warm cache skipped the step; the step ran no work.failed— the step or the execution threw, or the exit code was non-zero.
Root span attributes
The sandbox.startup root span uses this closed attribute allowlist.
| Attribute | Type | Optional | Meaning |
|---|---|---|---|
paperclip.sandbox.startup.root.wall_ms |
number | no | The root-span wall time of the whole bring-up. |
paperclip.sandbox.startup.root.work_ms |
number | no | The sum of the step wall times. |
paperclip.sandbox.startup.root.diff_ms |
number | no | work_ms − wall_ms; the overlap the parallel steps saved. |
paperclip.sandbox.startup.provider |
string | yes | The normalized provider family. |
paperclip.sandbox.startup.cold_start |
boolean | yes | Whether the bring-up is a cold start. |
paperclip.sandbox.startup.region |
string | yes | The clamped region label. |
paperclip.sandbox.startup.image_id |
string | yes | The hashed image id. |
paperclip.sandbox.startup.sandbox_id |
string | yes | The hashed sandbox id. |
paperclip.sandbox.startup.lease_id |
string | yes | The hashed lease id. |
Step span attributes
Each bring-up step span uses this closed attribute allowlist. The step name
rides the span name, so no step attribute repeats it.
| Attribute | Type | Optional | Meaning |
|---|---|---|---|
paperclip.sandbox.startup.step.wall_ms |
number | no | The wall time of the step. |
paperclip.sandbox.startup.outcome |
string | no | The step outcome (ok, skipped, or failed). |
paperclip.sandbox.startup.provider |
string | yes | The normalized provider family. |
paperclip.sandbox.startup.batch |
string | yes | A shared tag that marks two parallel steps as one batch. |
paperclip.sandbox.startup.handshake.create_runtime.wall_ms |
number | yes | The create-runtime sub-time of the acp.handshake step. |
paperclip.sandbox.startup.handshake.ensure_session.wall_ms |
number | yes | The ensure-session sub-time of the acp.handshake step. |
The round-trip count and the provider durations no longer ride a step span. The
per-execution sandbox.exec child spans carry that detail.
sandbox.exec span attributes
The sandbox.exec span uses this closed attribute allowlist. Paperclip omits a
numeric attribute when the provider does not report the value.
| Attribute | Type | Optional | Meaning |
|---|---|---|---|
paperclip.sandbox.startup.provider |
string | no | The normalized provider family. |
paperclip.sandbox.startup.exec.command |
string | no | The clamped argv[0] command label. |
paperclip.sandbox.startup.exec.exit_code |
number | yes | The numeric process exit code. |
paperclip.sandbox.startup.exec.wall_ms |
number | no | The host-measured wall time of the execution. |
paperclip.sandbox.startup.exec.wait_before_ms |
number | yes | The provider handle-fetch wait before the execution ran. |
paperclip.sandbox.startup.exec.sandbox_ms |
number | yes | The in-sandbox run time of the execution. |
paperclip.sandbox.startup.exec.network_ms |
number | yes | The transport time the host adds; wall_ms − wait_before_ms − sandbox_ms. |
paperclip.sandbox.startup.exec.critical_path |
boolean | no | Whether the execution sits on the startup critical path. |
paperclip.sandbox.startup.exec.cache_hit |
boolean | yes | Whether the provider served the sandbox handle from its warm cache. |
paperclip.sandbox.startup.outcome |
string | no | The execution outcome (ok or failed). |
The plugin decides the cache hit at the sandbox-handle lookup. The span no
longer infers a cache hit from wait_before_ms == 0. Paperclip omits the
cache_hit attribute when the provider does not report the value.
To add a span attribute, extend the SANDBOX_STARTUP_SPAN_ATTRS allowlist in
the code first. Keep the attribute low-cardinality and free of user content.
Provider spans
A sandbox provider plugin also opens spans for its own sync steps. These spans
use the sandbox.daytona. name prefix. They share the
paperclip.sandbox.startup. attribute prefix and obey the same opt-in and
no-user-content rules as the startup spans above.
The plugin worker runs in a separate process from the host. So the host treats
every field of a worker-sent span as untrusted input. The host re-clamps the
span name and every attribute at one boundary, the span.record host handler,
before it records the span.
| Span | Scope | Parent |
|---|---|---|
sandbox.daytona.pack |
The host-local pack step that builds the upload tarball. It makes no sandbox round trip. | the active startup step span |
sandbox.daytona.transfer |
The transfer step: an upload to the sandbox (inbound) or a download from the sandbox (outbound). The paperclip.sandbox.startup.transfer.direction attribute records the direction. |
the active sync task span (stage.* inbound, restore.* under sandbox.syncBack outbound) |
sandbox.daytona.ensureDirectory |
The mkdir -p step that ensures a directory exists before a write. |
the active startup step span |
sandbox.daytona.checkSymlinkEscape |
The re-check step that a path resolves inside the workspace root before use. | the active startup step span |
sandbox.daytona.promote |
The atomic move of a staged temp onto its target via a pinned dir handle. | the active startup step span |
sandbox.daytona.extractTarball |
The one round trip that re-checks the path, runs tar -xf, and removes the scratch tarball. |
the active startup step span |
sandbox.daytona.postUploadCommand |
One caller-supplied post-upload command. | the active startup step span |
sandbox.daytona.session.open |
The create of the one persistent session for a lease, on the first in-run command. | the active run span |
sandbox.daytona.session.close |
The delete of that persistent session on lease release. | the active run span |
sandbox.daytona.other |
Any span name outside the known set. | the active startup step span |
The host clamps the span name to the closed set of leaf names above (pack,
transfer, ensureDirectory, checkSymlinkEscape, promote, extractTarball,
postUploadCommand, session.open, and session.close). The host maps a known
name to sandbox.daytona.<name>. The host maps any other value to
sandbox.daytona.other, so a span name never carries free-form data. Only the
daytona provider emits these spans today, so the segment is the literal
daytona.
The sandbox.daytona.* spans use this closed attribute allowlist. The host
drops every other key, so a command, an argument, a path, an id, a standard
output, or a standard error never rides a provider span. The host records only
the attributes that the producer sends for one span.
| Attribute | Type | Optional | Meaning |
|---|---|---|---|
paperclip.sandbox.startup.provider |
string | no | The normalized provider family. |
paperclip.sandbox.startup.outcome |
string | yes | The step outcome (ok, skipped, or failed). |
paperclip.sandbox.startup.pack.wall_ms |
number | yes | The host-local wall time of the pack step. It rides the sandbox.daytona.pack span. |
paperclip.sandbox.startup.transfer.wall_ms |
number | yes | The wall time of the transfer step. It rides the sandbox.daytona.transfer span. |
paperclip.sandbox.startup.transfer.guard.count |
number | yes | The number of serial guard round trips before one transfer. It rides the sandbox.daytona.transfer span. |
paperclip.sandbox.startup.transfer.direction |
string | yes | The transfer direction (inbound or outbound). It rides the sandbox.daytona.transfer span. |
The span.record host handler enforces the allowlist. It re-maps provider
through the provider-family normalizer. It keeps outcome only when the value
is ok, skipped, or failed. It keeps transfer.direction only when the
value is inbound or outbound. It keeps a numeric attribute only when the
value is a finite number. It drops a status message and keeps only the numeric
status code. The handler never throws, because observability must not change the
sync control flow.
The span.record host method needs the environment.drivers.register
capability. So only a plugin that registers an environment driver may emit a
provider span. The capability gate rejects a provider span from any other
plugin.
The host parents each provider span to the active sync task span. An inbound
transfer runs inside a stage.* task span, so its provider spans parent there.
An outbound transfer runs inside a restore.* task span under sandbox.syncBack
at teardown, so its provider spans parent there. The host mints a W3C
traceparent from the active task span and passes it to the plugin worker on the
per-call invocation channel. The teardown restore runs inside the run-parented
sandbox.syncBack span, so the host mints a traceparent for an outbound
provider span the same way it does for an inbound one. The worker tags its span with the
traceparent and treats the value as opaque. The worker never derives the
parent from it. The host recovers the traceparent from its own invocation
record, so a worker can never forge a parent. The host validates the
traceparent and rejects a missing or malformed value. With no active host
trace context the worker sends no span, so the whole provider-span path is a
no-op.
Sandbox Startup Run-Log Event
Paperclip writes one run.startup.step event to the run log for each bring-up
step. This event is a run-log record, not a first-party telemetry event. The
generated telemetry contract does not cover it, so this section is its canonical
contract.
The event payload carries only three fields.
| Field | Type | Meaning |
|---|---|---|
step |
string | The bring-up step name, for example stage.sync. |
durationMs |
number | The wall time of the step. A skipped step reports 0. |
outcome |
string | The step outcome (ok, skipped, or failed). |
The event no longer carries the per-step round-trip count or the provider
duration fields. It dropped roundTrips, providerExecMs, providerGetMs,
createRuntimeMs, and ensureSessionMs. The startup spans in the section above
carry that detail now. The sandbox.exec child spans hold the round-trip and
provider durations. The acp.handshake step span holds the create-runtime and
ensure-session sub-times.
To read the detailed timing, use the startup spans. The spans need an OTLP endpoint. A run with no endpoint keeps only the three run-log fields above.
Run Phase Timing Run-Log Event
Paperclip writes one run.phase.timing event to the run log for each
run-lifecycle phase. This event is a run-log record, not a first-party telemetry
event. The generated telemetry contract does not cover it, so this section is its
canonical contract. The producer is emitRunPhaseTiming in
packages/adapter-utils/src/acpx-engine/startup-timing.ts.
The event payload carries only three fields.
| Field | Type | Meaning |
|---|---|---|
phase |
string | The run-lifecycle phase name from the closed allowlist below. |
durationMs |
number | The wall time of the phase. A negative or a non-finite value clamps to 0. |
outcome |
string | The phase outcome (ok or failed). |
The phase field is one member of a closed, low-cardinality allowlist. The
producer drops any event whose phase name is outside this allowlist, so a
free-form label never reaches the run log. The allowlist has twelve phase names.
| Phase | Meaning |
|---|---|
place_workspace |
Place the run workspace. |
start_transport |
Start the agent transport. |
create_runtime |
Create the agent runtime. |
ensure_session |
Ensure the agent session exists. |
configure_session |
Configure the agent session. |
prepare_turn |
Prepare the turn. |
turn |
Run the turn. |
end_session |
End the agent session. |
settle_reuse |
Settle the session for reuse. |
stop_transport |
Stop the agent transport. |
sync_back |
Sync the workspace back. |
release_staging_lease |
Release the staging lease. |
The payload never carries a command, an argument, a path, an environment value,
or a raw identifier. The event rides the ctx.onEvent run-event bridge and is
observability-only. It needs no OTLP endpoint.
Dimension Values
Telemetry dimension values must be primitives. Use only the value types allowed
by TelemetryDimensionValue:
stringnumberboolean
Do not emit null, undefined, arrays, or objects as dimension values. Optional
dimensions should be omitted when absent.
When a dimension is enum-like, use the shared constant from constants.ts when
one exists. If no shared constant exists, use the generated telemetry type as the
domain. In all cases, the generated telemetry type remains the source of truth
for the emitted value.
Required, Optional, And Sentinel Values
Required and optional dimensions are defined by EventDimensionsMap.
Required dimensions must be present for every event of that name. Optional dimensions should be emitted only when the value is known and useful.
Sentinel values are only for required fields that have no observed raw value at the emitting layer. Do not use a sentinel to hide a concrete value that is new, custom, or not yet represented by a shared constant. Emit the concrete raw value and let the receiving layer canonicalize it.
Adding Or Changing Telemetry
Client code is responsible for emitting approved telemetry events at the right place in the product. Stable event names, dimensions, and enum domains must come from the generated telemetry contract before normal emitters use them.
For product work that needs to propose a new first-party event before schema
registration, use the proposal marker workflow in doc/TELEMETRY_WORKFLOW.md.
Those proposed calls stay on client.track(), carry an @ts-expect-error
marker on the event-name argument, and are swallowed at runtime until the
generated schema registers the event name.
For stable event work:
- Start from
generated/paperclip-telemetry.ts. The generated types are what reviewers use to verify event names, dimensions, optionality, value types, enum descriptions, and schema version. - Choose stable event and dimension names. Do not include user content, local machine details, secrets, credentials, private paths, or values that are not part of the public event contract.
- Use only
string,number, orbooleandimension values. - Reuse a shared constant from
constants.tsfor enum-like dimensions when one exists. If the generated telemetry domain has values beyond a shared constant, keep the emitter aligned with the generated telemetry type. - Keep emitters raw. Do not normalize, alias-map, or lowercase enum-like values in the client unless the generated contract explicitly calls for that emitted value.
- Add or update a typed helper in
events.tswhen the event is first-party and should have a stable helper API. - Update tests for helper behavior, including raw pass-through for enum-like values when that is the intended boundary.
- Update this README only when the contributor workflow, source-of-truth pointers, or durable invariants change. Do not add an event catalog here.
Before opening a pull request, verify that the emitted code, typed helpers, and generated telemetry contract agree. If they disagree, fix the contract or code rather than documenting around the mismatch in this README.
For new first-party events that are not in the generated contract yet, follow
the public proposal and promotion workflow in
doc/TELEMETRY_WORKFLOW.md.
Retention
Retention windows are documented in retention.ts. Each event is assigned a
retention class; the class determines the window in days. This is a
housekeeping and query-cost concern managed by data-infra, not a schema
concern — updating a retention window does not require a schema version bump.
Current classes:
| Class | Window | Description |
|---|---|---|
operational_enum_count |
90 days | Enum/boolean/count/bucket events. No token material, no PII. |
When a new event carries only enums, booleans, counts, or coarse buckets and
no token material or PII, assign it to operational_enum_count in
EVENT_RETENTION_CLASS. If no existing class fits, define a new class in
RETENTION_DAYS and document it here.