Bridge protocol

Harn's stdio bridge uses JSON-RPC 2.0 notifications and requests for host/runtime coordination below ACP session semantics.

Tool lifecycle observation

The tool/pre_use, tool/post_use, and tool/request_approval bridge request/response methods have been retired in favor of the canonical ACP surface:

  • Tool lifecycle is now carried on the session/update notification stream as tool_call and tool_call_update variants (see the ACP schema at https://agentclientprotocol.com/protocol/schema). Hosts observe every dispatch via the session update stream — there is no host-side approve/deny/modify hook at dispatch time.
  • Approvals route through canonical session/request_permission. When harn's declarative ToolApprovalPolicy classifies a call as RequiresHostApproval, the agent loop issues a session/request_permission request to the host and fails closed if the host does not implement it (or returns an error).

Internally, the agent loop emits AgentEvent::ToolCall + AgentEvent::ToolCallUpdate events; the packaged harn-serve ACP adapter translates them into session/update notifications via an AgentEventSink it registers per session.

ACP compatibility contract

Harn-owned ACP/MCP extension fields are specified in Harn ACP/MCP extensions v1. Harn tracks the upstream Agent Client Protocol schema and pins its wire contract against agentclientprotocol/agent-client-protocol schema v0.12.2. The adapter treats these session/update values as standard ACP variants:

  • user_message
  • user_message_chunk
  • agent_message_chunk
  • agent_thought_chunk
  • tool_call
  • tool_call_update
  • plan

Harn emits additional host-facing lifecycle updates outside ACP. They ride as top-level sessionUpdate discriminators for compatibility with existing Burin Code and other host renderers, advertised during initialize under agentCapabilities._meta.harn.sessionUpdateExtensions:

  • artifact
  • available_commands_update
  • fs_watch
  • handoff
  • hitl_request
  • hitl_resolved
  • log
  • progress
  • reminder_emitted
  • skill_activated
  • skill_deactivated
  • skill_narrow
  • skill_scope_tools
  • tool_search_query
  • tool_search_result
  • transcript_compacted
  • transcript_projected
  • worker_update

Unknown values should be ignored per ACP forward-compatibility rules. Hosts that render Harn extensions should key off the explicit extension list from initialize instead of a local allow-list.

Harn keeps its tool-rendering extensions under tool_call._meta.harn and tool_call_update._meta.harn, following ACP's extension convention while leaving canonical ACP fields at their standard locations. These Harn metadata keys are advertised during initialize under agentCapabilities._meta.harn.toolLifecycleExtensionFields:

  • audit
  • durationMs
  • error
  • errorCategory
  • executionDurationMs
  • executor
  • parsing
  • rawInputPartial

The standard ACP fields (toolCallId, title, kind, status, content, locations, rawInput, and rawOutput) remain available in their ACP locations. Hosts migrating from pre-#904 builds should read the listed Harn fields from _meta.harn instead of the tool-call update root. Harn's pinned fixtures under crates/harn-serve/tests/fixtures/acp/ pin both the standard and extension shapes so host integrations can reference stable examples.

Vendor-extension session-update payload namespacing (#905)

Following the ACP extensibility convention, every vendor field on a Harn extension session-update is emitted under update._meta.harn — never as a bare top-level field. The sessionUpdate discriminator stays at the canonical location so existing dispatchers that route on it keep working unchanged. Concretely:

Update variantVendor fields under _meta.harn
artifactartifactId, kind, title, mimeType, spec, fallback, sizeBytes, provenance, metadata
progressphase, message, progress, total, data
loglevel, message, fields
fs_watchsubscriptionId, events
worker_updateworkerId, workerName, workerTask, workerMode, event, status, terminal, metadata, audit
transcript_compactedmode, reason, strategy, archivedMessages, estimatedTokensBefore, estimatedTokensAfter, snapshotAssetId, instructionMode, instructionSource, compactionPolicy
transcript_projectedpolicy, reason, prefixHash, keptCount, droppedCount, providerSafetyBlocked, redactedCount, reclaimedTokens, rootsConsulted, redactionPointers
handoffhandoffId, artifactId, handoff
skill_activatedskillName, iteration, reason
skill_deactivatedskillName, iteration
skill_scope_toolsskillName, allowedTools
tool_search_querytoolUseId, name, query, strategy, mode
tool_search_resulttoolUseId, promoted, strategy, mode
hitl_requestrequestId, kind, payload
hitl_resolvedrequestId, kind, outcome
reminder_emittedreminder

Hosts migrating from pre-#905 builds must read these fields from _meta.harn.<field> instead of the update root. The fixture crates/harn-serve/tests/fixtures/acp/session_update_extensions.json pins the new wire shape verbatim.

reminder_emitted is sent when a pending system reminder is rendered into the next model request. Its payload lives at update._meta.harn.reminder:

{
  "sessionUpdate": "reminder_emitted",
  "_meta": {
    "harn": {
      "reminder": {
        "reminderId": "reminder-1",
        "tags": ["token_pressure"],
        "body": "Refresh the compacted context before answering.",
        "roleHint": "developer",
        "renderedRole": "developer",
        "source": "stdlib_provider",
        "ttlTurns": 2
      }
    }
  }
}

Content extensions (visible_text and visible_delta on the agent_message_chunk content block, advertised via agentCapabilities._meta.harn.contentExtensionFields) follow the same convention but ride under content._meta.harn because they extend the canonical ACP content block, not the session-update envelope. Example:

{
  "sessionUpdate": "agent_message_chunk",
  "content": {
    "type": "text",
    "text": "hello",
    "_meta": {
      "harn": {
        "visible_text": "hello",
        "visible_delta": "hello"
      }
    }
  }
}

Composition event migration note

Governed Code Mode composition runs are represented on the Harn _harn/agentEvent extension channel, not as one opaque tool call and not as new ACP session/update variants. Consumers that do not render composition events can ignore unknown _harn/agentEvent.kind values and continue reading ordinary tool_call / tool_call_update events.

The Harn-native MVP is surfaced by composition_binding_manifest(...), composition_execute(...), and the std/composition composition_mcp_tools(...) profile. Binding manifests are hashable prompt contracts; execution reports carry the same parent/child fields described here. TypeScript declarations generated from a manifest are editor/model affordances, not the bridge authority.

Consumers that do render them should group by runId:

  • composition_start identifies the snippet language, snippet hash, binding-manifest hash, and requested side-effect ceiling.
  • composition_child_call records each child binding operation with rawInput, policyContext, requestedSideEffectLevel, and the ToolAnnotations used for policy decisions.
  • composition_child_result records the child status, executor, output/error, and timing.
  • composition_finish and composition_error are terminal parent events carrying stdout, stderr, artifacts, structured result, duration, and failure category.

Portal and transcript indexers should treat composition events as a parent/child grouping overlay. A mutating child remains a mutating child operation because its own annotations and side-effect level are present; do not infer the whole run is read-only from the parent event alone.

audit tag

Both tool_call and tool_call_update carry an optional _meta.harn.audit field that mirrors the active mutation session for the dispatch (see Trust boundary). Hosts use it to:

  • group every tool emission belonging to the same write-capable session (so undo/redo and audit logs never cross sessions even when multiple workers run in parallel);
  • correlate the canonical tool_call stream against session/update.worker_update.audit and the optional session/request_permission.mutation payloads — they all carry the same MutationSessionRecord, so a host that already understands one reuses the same codepath for the others;
  • decide whether to surface a tool dispatch in trust-boundary UX (e.g. badge writes that escape mutation_scope: read_only) without guessing from the tool name.

Wire shape (snake_case fields, matching the existing worker_update.audit contract):

{
  "_meta": {
    "harn": {
      "audit": {
        "session_id": "session_42",
        "parent_session_id": "session_root",
        "run_id": "run_42",
        "worker_id": "worker_3",
        "execution_kind": "worker",
        "mutation_scope": "apply_workspace",
        "approval_policy": {
          "auto_approve": [],
          "auto_deny": [],
          "require_approval": ["edit_*"],
          "write_path_allowlist": ["src/**"]
        }
      }
    }
  }
}

The _meta.harn.audit field is omitted when no mutation session is installed (read-only harn run invocations, conformance fixtures, scripts that don't enter a workflow). Worker lifecycle audit data is carried as worker_update._meta.harn.audit, matching the Harn extension namespacing contract.

executor tag

tool_call_update carries an optional _meta.harn.executor field that names the backend that ran the tool, so clients can render "via mcp:linear" / "via host bridge" badges, attribute latency by transport, and route errors correctly. Variants:

  • "harn_builtin" — VM-stdlib (e.g. read_file, write_file, exec, http_*, mcp_*) or any Harn-side handler closure registered in the script's tools table.
  • "host_bridge" — capability provided by the host through the bridge (Swift IDE bridge, BurinApp, BurinCLI host shells).
  • {"kind": "mcp_server", "serverName": "<name>"} — tool came from mcp_list_tools against the named server. The agent loop detects this from the _mcp_server annotation mcp_list_tools injects on every dict, so the tag survives even when the call physically proxies through a bridge.
  • "provider_native" — provider executed the tool server-side and inlined the result (currently only OpenAI Responses-API tool_search and the equivalent Anthropic native search; the agent never dispatches these locally).

Unit variants serialize as bare strings; the mcp_server case carries the configured server name. The field is omitted when unknown — most commonly for the in-progress emission that fires before the dispatch backend is picked.

session/request_permission

Request payload (harn-issued):

{
  "sessionId": "session_123",
  "approvalRequest": {
    "id": "tool-call_123",
    "action": "edit_file",
    "args": {"path": "src/main.rs"},
    "principal": "worker_3",
    "requested_at": "2026-04-30T12:00:00Z",
    "approvers_required": 1,
    "evidence_refs": [
      {
        "kind": "workspace_path",
        "ref": "src/main.rs",
        "metadata": {"workspace_path": "src/main.rs"}
      }
    ],
    "undo_metadata": {
      "session_id": "session_123",
      "run_id": "run_123",
      "worker_id": null,
      "mutation_scope": "apply_workspace",
      "policy_decision": {
        "type": "harn.permission_policy_decision.v1",
        "action": "ask",
        "reason": "workspace edit",
        "matched_rule": {"source": "rules", "action": "ask", "id": "edit-src", "index": 0},
        "risk_labels": ["approval_required", "path_rule"]
      }
    },
    "capabilities_requested": ["workspace.write_text"]
  },
  "policyDecision": {
    "type": "harn.permission_policy_decision.v1",
    "action": "ask",
    "reason": "workspace edit",
    "matched_rule": {"source": "rules", "action": "ask", "id": "edit-src", "index": 0},
    "risk_labels": ["approval_required", "path_rule"],
    "context": {
      "tool_name": "edit_file",
      "tool_kind": "edit",
      "side_effect": "workspace_write",
      "paths": [{"workspace_path": "src/main.rs"}]
    }
  },
  "toolCall": {
    "toolCallId": "call_123",
    "toolName": "edit_file",
    "rawInput": {"path": "src/main.rs"}
  },
  "mutation": {
    "session_id": "session_123",
    "run_id": "run_123",
    "worker_id": null,
    "mutation_scope": "apply_workspace",
    "approval_policy": {"require_approval": ["edit*"]}
  },
  "declaredPaths": ["src/main.rs"]
}

approvalRequest is the canonical Harn ApprovalRequest payload. Hosts should render approval UI from that object and use policyDecision as the policy rationale: it includes the matched rule, risk labels, normalized context, and the action (ask for host approval requests). The same receipt is copied into approvalRequest.undo_metadata.policy_decision and into PermissionGrant/PermissionDeny transcript-event metadata so approvals are auditable and replayable even when the host only persists the canonical request. Treat the surrounding toolCall, mutation, and declaredPaths fields as compatibility/context fields. The same shape is used in stdlib HITL notifications at harn.hitl.requested.params.payload.approval_request, so Burin Code, harn-cloud approval inboxes, and CLI-style hosts can share one renderer.

Field meanings:

  • id: stable approval request id. For tool permission prompts this is the emitted tool-call id; for stdlib HITL it is the HITL request id.
  • action: human-readable action name, usually the tool or stdlib action.
  • args: structured action arguments safe for host rendering.
  • principal: agent, worker, session, or other actor requesting approval.
  • requested_at: RFC3339 UTC timestamp.
  • deadline: optional RFC3339 UTC deadline. Omitted when there is no deadline.
  • approvers_required: number of approving reviewers required to proceed.
  • evidence_refs: host-renderable evidence handles, such as declared workspace paths or run artifacts.
  • undo_metadata: mutation/audit metadata a host can use to group undo/redo.
  • capabilities_requested: canonical capability operation names requested by the action.

Response payload (host-issued):

  • { "outcome": { "outcome": "selected" } } (ACP canonical): granted
  • { "granted": true } (legacy shim): granted with original args
  • { "granted": true, "args": {...} }: granted with rewritten args
  • { "granted": false, "reason": "..." }: denied

Worker lifecycle notifications

Delegated workers emit session/update notifications with worker_update content. Those payloads include lifecycle timing, child run/snapshot paths, and audit-session metadata so hosts can render background work without scraping plain-text logs.

Lifecycle states

The runtime emits one of seven typed lifecycle events per worker transition. The wire status string is the same value harn writes to the worker's persisted state, so it round-trips through the bridge unchanged:

EventstatusMeaning
WorkerSpawnedrunningA worker (delegated stage, workflow, or sub-agent) has begun a new cycle.
WorkerProgressedprogressedA retriggerable worker is resuming after worker_trigger. Followed shortly by another running from the new cycle.
WorkerWaitingForInputawaiting_inputA retriggerable worker has finished its current cycle and is parked waiting for the next host trigger payload.
WorkerCompletedcompletedA non-retriggerable worker has finished successfully (terminal).
WorkerFailedfailedA worker terminated with an error (terminal).
WorkerStoppedstoppedA worker stopped gracefully after emitting a typed handoff summary (terminal).
WorkerCancelledcancelledA worker was cancelled via close_agent or upstream cancellation (terminal).

The four terminal events end the worker's lifetime. Progressed and WaitingForInput are explicitly non-terminal — observers should expect more events on the same worker_id after they fire.

worker_update notification shape

ACP and A2A adapters subscribe to the canonical AgentEvent::WorkerUpdate variant and translate it into their respective wire formats from one typed source. ACP emits a session/update with sessionUpdate: "worker_update". Per the vendor-extension namespacing rule, all worker fields ride under update._meta.harn:

{
  "jsonrpc": "2.0",
  "method": "session/update",
  "params": {
    "sessionId": "session_123",
    "update": {
      "sessionUpdate": "worker_update",
      "_meta": {
        "harn": {
          "workerId": "worker_abc",
          "workerName": "review_captain",
          "workerTask": "Review PR #42",
          "workerMode": "delegated_stage",
          "event": "WorkerWaitingForInput",
          "status": "awaiting_input",
          "terminal": false,
          "metadata": {
            "task": "Review PR #42",
            "mode": "delegated_stage",
            "started_at": "0193...",
            "finished_at": null,
            "awaiting_started_at": "0193...",
            "child_run_id": "run_xyz",
            "child_run_path": ".harn-runs/run_xyz",
            "snapshot_path": ".harn/workers/worker_abc.json",
            "audit": { "...": "MutationSessionRecord" },
            "error": null
          },
          "audit": { "...": "MutationSessionRecord" }
        }
      }
    }
  }
}

The event discriminator is the typed WorkerEvent variant name; the status field is the same lower-case value the legacy bridge status field carried. terminal is a derived hint so clients can decide whether to retain the worker in their tracking UI without parsing the event name.

A2A surfaces the same event as a task-stream entry of type worker_update, scoped to the task whose dispatch spawned the worker. A2A is a separate protocol surface and keeps the worker fields at the task-stream entry root (it has its own envelope conventions). ACP hosts should always read worker fields from update._meta.harn.<field>.

Structured Harn plan emissions use the same task stream. When an agent calls emit_plan or update_plan, A2A subscribers receive a harn_plan entry with standard ACP-compatible entries plus the normalized harn.plan.v1 artifact under plan.

Agent progress reports use protocol-native A2A status updates. When agent_progress emits narration or entries, task-stream subscribers receive a non-terminal status-update event with type: "status", status.state: "working", a populated status.message, and final: false. Entry lists render as a deterministic markdown checklist inside the message text. Final task completion still emits the terminal completed status separately.

External-agent delegation

Harn core exposes an open external-agent contract for paid or autonomous delegation over interoperable transports. It does not contain proprietary adapters; hosted products, Harn Cloud, or connector packages should translate their own APIs into the capability metadata and extension methods below.

Agent Cards advertise support under one of:

  • capabilities.externalAgent
  • capabilities._meta.harn.externalAgent
  • _meta.harn.externalAgent

The object uses schema id harn.external_agent.v1 and should include:

{
  "schema": "harn.external_agent.v1",
  "pre_dispatch_checkpoint": true,
  "budget_cap": true,
  "idempotency": true,
  "reviewable_handoff": true,
  "dispatch": true,
  "operations": [
    "_harn/externalAgent.plan",
    "_harn/externalAgent.dispatch"
  ]
}

A2A peers expose these as JSON-RPC extension methods on the card's JSONRPC transport:

  • _harn/externalAgent.plan returns a checkpoint with checkpoint_id, plan, expected_scope, optional evidence_refs, and metadata.
  • _harn/externalAgent.dispatch accepts the approved checkpoint, budget, idempotency_key, task, expected_scope, context, and metadata, then returns a result with a reviewable handoff, artifacts, diff/patch, receipts, and budget_used (usd, tokens, seconds, tool_calls) where applicable.

std/external_agent.external_agent_delegate(target, task, options?) enforces the safe sequence:

  1. Validate that budget has at least one positive cap and that an idempotency_key is present or generated by the stdlib wrapper.
  2. Resolve the Agent Card and prove budget_cap, idempotency, reviewable_handoff, and dispatch support before any paid work.
  3. Fetch a pre-dispatch checkpoint and return status: "checkpoint_required" unless checkpoint.approved is already true.
  4. Dispatch only after approval, preserving the same idempotency key. Replays of a completed delegation return status: "replayed" without another remote dispatch.
  5. Normalize the result into a typed harn.external_agent.handoff.v1 envelope with a handoff artifact and a diff artifact when a patch is present.

If a peer cannot provide a remote checkpoint, normal dispatch is refused. Hosts may set checkpoint.allow_local_fallback: true with an explicit local plan; Harn will still require the remote peer to advertise budget, idempotency, reviewable handoff, and dispatch support before sending the approved task. When a peer reports budget_used above the cap, Harn returns a reviewable envelope with status: "budget_exceeded" rather than silently treating the work as clean.

Daemon idle/resume notifications

Daemon agents stay alive after text-only turns and wait for host activity with adaptive backoff: 100ms, 500ms, 1s, 2s, resetting to 100ms whenever activity arrives.

agent/idle

Sent as a bridge notification whenever the daemon enters or remains in the idle wait loop.

Payload:

{
  "iteration": 3,
  "backoff_ms": 1000
}

agent/resume

Hosts can send this notification to wake an idle daemon without injecting a user-visible message.

Payload:

{}

On the ACP adapter surface, hosts can wake the daemon by sending a pending user-message inject. session/inject accepts {sessionId, mode, content} where content is a string or ACP content-block array, then responds immediately with status: "accepted" and an agent-owned messageId. Harn later delivers the same id as session/update with sessionUpdate: "user_message". mode: "steer" maps to the next safe operation boundary, and mode: "queue" maps to end-of-interaction delivery. Pending injects can be revoked with session/revoke_inject or edited in place with session/replace_inject before delivery; successful mutation responses return status: "revoked", "already_revoked", or "replaced". Races return structured error data with reason: "already_delivered", "already_revoked", "unknown_message_id", or "not_owner_or_not_authorized".

To inject ambient context without pretending it is a user message, send session/remind:

{
  "body": "The workspace changed while you were idle; re-read src/lib.rs before editing.",
  "tags": ["workspace"],
  "dedupe_key": "workspace-change",
  "ttl_turns": 2,
  "role_hint": "system",
  "mode": "interrupt_immediate",
  "_meta": {
    "harn": {
      "origin": "file-watcher"
    }
  }
}

mode accepts the same delivery values as queued user messages: interrupt_immediate, finish_step, and audit_only. audit_only reminders drain at loop_exit and land in the transcript but are never rendered into a model prompt — see steering seams for the full table. The reminder payload is validated as a reminder spec; non-standard host metadata belongs under _meta. Malformed reminder payloads are rejected with HARN-RMD-002; unknown top-level reminder options are rejected with HARN-RMD-001.

Hosts that need an operator-facing queue can call session/pending_injections with { "sessionId": "..." }. The response is {pendingCount, injections} in FIFO order, with kind: "user" rows for pending session/inject messages and kind: "reminder" rows for pending session/remind reminders. Reminder rows include reminderId, mode, body, tags, dedupeKey, ttlTurns, roleHint, and source.

Queued reminders can be revoked before delivery with session/revoke_reminder and { "sessionId": "...", "reminderId": "..." }. Successful revocation returns {reminderId, status: "revoked"}; repeated revocation returns status: "already_revoked". Races after a checkpoint drains the reminder return a structured already_delivered error.

Live session attach/detach

Interactive clients attach to an existing live session with a Harn-owned lifecycle contract. Hosted/cloud brokers can authenticate and route these calls, but the runtime owns the session semantics so local TUIs, portals, and remote clients do not invent incompatible takeover rules.

session/attach accepts:

{
  "sessionId": "session_123",
  "clientId": "portal",
  "mode": "observer",
  "takeover": false,
  "_meta": {
    "harn": {
      "surface": "portal"
    }
  }
}

mode is observer or controller. A session may have many observers and at most one controller. A second controller attach fails unless takeover: true is set or the client calls session/takeover, which demotes the prior controller to observer. session/detach removes the client and releases control when the detaching client was the controller. session/heartbeat refreshes the client's last-seen marker and optional metadata without changing ownership.

Responses share this shape:

{
  "client": {
    "client_id": "portal",
    "mode": "observer",
    "attached_at": "1780000000",
    "last_seen_at": "1780000000",
    "prompt_injection": false,
    "permission_routing": false,
    "metadata": {"surface": "portal"}
  },
  "previous_controller_id": null,
  "active_controller_id": "tui",
  "clients": []
}

Only the active controller may inject user prompts or route permission requests. Controller prompt injection records the client id on the user message metadata. Permission routing records a live_session_permission_route transcript event containing the request id, controller client, and host metadata. Attach, takeover, detach, and heartbeat record live_session_client transcript events and are also emitted as session/update extensions using the same _meta.harn namespacing rule as other Harn session-update extensions.

Unexpected client exit is represented as session/detach with reason: "client_exit" by the host/broker that detects the disconnect. Reconnect is a fresh session/attach using the same clientId; the runtime preserves the transcript and recomputes ownership from the live-client registry.

When a Harn script opts into tool_search against a provider that lacks native defer-loading support, the runtime switches to a client-executed fallback (see the LLM tools guide). Built-in bm25, regex, and hybrid strategies stay in Harn/VM space. Hosts that want embeddings, LLM reranking, project-specific indexes, or remote search services should provide those through ordinary Harn callbacks, host-backed tools, or MCP tools and pass the closure as tool_search.strategy. The bridge observes only the resulting tool_search_query and tool_search_result transcript events; there is no special tool_search/query RPC.

  • tool_names (required): ordered list of tool names to promote. Unknown names are ignored by the runtime — they can't be surfaced because their schemas weren't registered. Return at most ~20 names per call; the runtime caps promotions soft-per-turn regardless.
  • diagnostic (optional): short explanation surfaced to the model in the tool result alongside tool_names. Useful for "no hits, try broader terms"-style feedback.

An ACP-style wrapper { "result": { "tool_names": [...] } } is also accepted for hosts that re-wrap everything in a result envelope.

Errors: a JSON-RPC error response (standard shape) is surfaced to the model as a tool_names: [] result with a diagnostic that includes the host error message. The loop continues — the model can retry with a different query.

Host tool discovery

Hosts can expose their own dynamic tool surface to scripts without pre-registering every tool in the initial prompt. Harn discovers that surface through one bridge RPC and then invokes individual tools through the existing builtin_call request path.

host/tools/list

VM-issued request. No parameters (or an empty object). The host responds with a list of tool descriptors. Canonical response shape:

{
  "tools": [
    {
      "name": "Read",
      "description": "Read a file from the active workspace",
      "schema": {
        "type": "object",
        "properties": {
          "path": {"type": "string", "description": "File path to read"}
        },
        "required": ["path"]
      },
      "deprecated": false
    },
    {
      "name": "open_file",
      "description": "Reveal a file in the editor",
      "schema": {
        "type": "object",
        "properties": {
          "path": {"type": "string"}
        },
        "required": ["path"]
      },
      "deprecated": true
    }
  ]
}

Accepted variants:

  • a bare array [{...}, {...}]
  • an ACP-style wrapper { "result": { "tools": [...] } }
  • compatibility field names short_description, parameters, or input_schema; Harn normalizes them to description and schema

Each normalized descriptor surfaced to scripts has exactly these keys:

  • name: string, required
  • description: string, defaults to ""
  • schema: JSON Schema object or null
  • deprecated: boolean, defaults to false

Invocation:

  • host_tool_list() returns the normalized list directly.
  • host_tool_call(name, args) then dispatches that tool through the existing builtin_call bridge request using name as the builtin name and args as the single argument payload.

Shell discovery host capability

Shell discovery is a typed process host capability so IDEs, TUIs, headless CLI runs, and cloud workers can share one shell-selection contract. Harn's standalone fallback exposes the same operations when no bridge host is attached.

process.list_shells

Called through host_call("process.list_shells", {}). The response is:

{
  "shells": [
    {
      "id": "zsh",
      "label": "zsh",
      "path": "/bin/zsh",
      "platform": "darwin",
      "available": true,
      "supports_login": true,
      "supports_interactive": true,
      "default_args": ["-c"],
      "login_args": ["-l", "-c"],
      "source": "env"
    }
  ],
  "default_shell_id": "zsh"
}

Windows hosts should distinguish pwsh, powershell, and cmd. cmd uses ["/C"]; PowerShell variants use ["-NoProfile", "-Command"].

process.get_default_shell / process.set_default_shell

process.get_default_shell returns the selected shell object for the session. Stateful hosts may implement process.set_default_shell with { "shell_id": "zsh" }; hosts that keep persistence in editor settings can omit persistence and still pass the selected shell on command requests.

process.shell_invocation

process.shell_invocation resolves a discovered shell ID or shell object into executable argv:

{
  "shell_id": "zsh",
  "command": "printf ok",
  "login": false,
  "interactive": false
}

Response:

{
  "program": "/bin/zsh",
  "args": ["-c", "printf ok"],
  "command_arg_index": 1,
  "shell": {"id": "zsh"}
}

Shell-mode command runners may pass a shell object or a shell ID resolved through this capability, and otherwise use the selected default shell. argv mode remains preferred for programmatic execution; shell mode is for user-authored commands and interactive shell semantics. The normative JSON Schema lives at spec/schemas/host-shell-discovery.schema.json.

Skill registry

Hosts expose their own managed skill store to the VM through three RPCs. Filesystem skill discovery works without the bridge (harn run walks the seven non-host layers described in Skills); these RPCs add a layer 8 so cloud hosts, enterprise deployments, and IDE hosts can serve skills the filesystem can't see.

skills/list

VM-issued request. No parameters (or an empty object). The host responds with an array of SkillManifestRef entries. Minimal shape:

[
  { "id": "deploy", "name": "deploy", "description": "Ship it", "source": "host" },
  { "id": "acme/ops/review", "name": "review", "description": "Code review", "source": "host" }
]

The VM also accepts { "skills": [ ... ] } for hosts that wrap collections in an object.

skills/fetch

VM-issued request. Parameters: { "id": "<skill id>" }. Response is a single skill object carrying enough metadata to populate a Skill:

{
  "name": "deploy",
  "description": "Ship it",
  "body": "# Deploy runbook\n...",
  "manifest": {
    "when_to_use": "...",
    "allowed_tools": ["bash", "git"],
    "paths": ["infra/**"],
    "model": "claude-opus-4-7"
  }
}

Hosts may flatten the manifest fields into the top level instead — the CLI accepts either shape.

skills/update

Host-issued notification. No parameters. Invalidates the VM's cached skill catalog; the CLI re-runs layered discovery (including another skills/list call) on the next iteration boundary — for harn watch, between file changes; for long-running agents, between turns. A VM without an active bridge ignores the notification.

Host-delegated skill matching

Harn agents that opt into skill_match: { strategy: "host" } (or the alias "embedding") delegate skill ranking to the host via a single JSON-RPC request. The host response is purely advisory — unknown skill names are ignored, and an RPC error falls back to the in-VM metadata ranker with a warning logged against agent.skill_match.

skill/match

Request payload (harn-issued, host response required):

{
  "strategy": "host",
  "prompt": "Ship the new release to production",
  "working_files": ["infra/terraform/cluster.tf"],
  "candidates": [
    {
      "name": "ship",
      "description": "Ship a production release",
      "when_to_use": "User says ship/release/deploy",
      "paths": ["infra/**", "Dockerfile"]
    },
    {
      "name": "review",
      "description": "Review existing code for correctness",
      "when_to_use": "User asks to review/audit",
      "paths": []
    }
  ]
}

Response payload (host-issued):

{
  "matches": [
    {"name": "ship", "score": 0.92, "reason": "matched by embedding similarity"}
  ]
}
  • matches[*].name (required): the candidate's skill name. Names absent from the original candidates list are ignored.
  • matches[*].score (optional): non-negative float; higher scores rank earlier. Defaults to 1.0 when omitted.
  • matches[*].reason (optional): short diagnostic stored on the skill_matched / skill_activated transcript events. Defaults to "host match".

Alternative shapes accepted for host convenience:

  • Top-level array: [{"name": ..., "score": ...}, ...]
  • {"skills": [...]} wrapping
  • {"result": {"matches": [...]}} ACP envelope

Skill lifecycle session updates

Agents emit ACP session/update notifications for skill lifecycle transitions so hosts can surface active-skill state in real time. These are Harn extension variants advertised during initialize, not upstream ACP SessionUpdate variants. The packaged harn-serve ACP adapter translates the canonical AgentEvent variants into:

  • sessionUpdate: "skill_activated"{skillName, iteration, reason}
  • sessionUpdate: "skill_deactivated"{skillName, iteration}
  • sessionUpdate: "skill_scope_tools"{skillName, allowedTools}
  • sessionUpdate: "skill_narrow"{reason, removedTools, remainingTools, policy, removedToolDetails, keptToolDetails}

skill_matched stays internal to the VM transcript — the candidate list can be large and host UIs typically only care about activation transitions, not every ranking pass.