MCP and ACP integration

Harn has built-in support for the Model Context Protocol (MCP), Agent Client Protocol (ACP), and Agent-to-Agent (A2A) protocol. This guide covers how to use each from both client and server perspectives.

For a scan-friendly status table across all protocol entry points, see the protocol support matrix. For shared serving internals, see Outbound workflow server. Harn-owned ACP/MCP extension fields are specified in Harn ACP/MCP extensions v1.

MCP client (connecting to MCP servers)

Connect to any MCP-compatible tool server, list its capabilities, and call tools from within a Harn program. Harn supports both stdio MCP servers and remote HTTP MCP servers.

Connecting manually

Use mcp_connect to spawn an MCP server process and perform the initialize handshake:

let client = mcp_connect("npx", ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"])

let info = mcp_server_info(client)
log("Connected to: ${info.name}")

mcp_server_info(...) also exposes the raw initialize response and an instructions string when the server supplied one. agent_loop(..., {mcp_servers: [...]}) can include those instructions as advisory context in the Harn-built system prompt; set mcp_initialize_advisory: false when a harness wants to keep server-provided advice out of model context.

Listing and calling tools

let tools = mcp_list_tools(client)
for t in tools {
  log("${t.name}: ${t.description}")
}

let content = mcp_call(client, "read_file", {path: "/tmp/data.txt"})
log(content)

mcp_call returns a string for single-text results, a list of content dicts for multi-block results, or nil when empty. If the tool reports an error, mcp_call throws.

Experimental file inputs

Harn implements SEP-2356 (the leading draft, not the older multipart upload proposal upstream closed) behind an explicit runtime opt-in: tool schemas mark file fields with x-mcp-file, and clients pass the selected file inline as an RFC 2397 data: URI.

harn.mcp.configure({
  experimental: {
    file_upload: {
      spec_revision: "modelcontextprotocol/modelcontextprotocol#2356",
    },
  },
})

let client = mcp_connect("python3", ["./image-server.py"])
let image = harn.mcp.upload_file(client, "photo.png", {
  accept: ["image/png", "image/jpeg"],
  max_size: 5242880,
})
let result = mcp_call(client, "describe_image", {image: image})
log(result)

harn.mcp.upload_file(...) reads a local file under Harn's filesystem policy, validates optional accept / max_size hints, and returns data:<media-type>;base64,.... Harn redacts data: URI payloads from replay keys and server-side diagnostics; scripts should still avoid printing them.

When Harn serves an MCP tool, use harn.mcp.file_input(...) in a tool_define parameter schema. The MCP server validates incoming data: URIs against the declared media-type and size constraints before invoking the handler.

var tools = tool_registry()

tools = tool_define(tools, "inspect_upload", "Inspect a small text file", {
  parameters: {
    upload: harn.mcp.file_input({
      accept: ["text/*"],
      max_size: 64,
      description: "Small text file to inspect",
    }),
  },
  handler: { args -> "received " + args.upload },
})

mcp_tools(tools)

Because SEP-2356 is still draft, the wire shape can change. The opt-in records the implemented proposal revision so experimental users have a clear cutover point when MCP ratifies file input support.

Resources and prompts

let resources = mcp_list_resources(client)
let data = mcp_read_resource(client, "file:///tmp/config.json")

let prompts = mcp_list_prompts(client)
let prompt = mcp_get_prompt(client, "review", {code: "fn main() {}"})

MCP client support matrix

Harn's MCP client negotiates protocol version 2025-11-25 and advertises the elicitation, sampling, and roots client capabilities. It answers server roots/list requests with the resolved project roots for the active script and emits notifications/roots/list_changed when that root snapshot changes. It does not advertise task capabilities, so servers should treat MCP tasks as unavailable when connected to Harn.

Method or featureHarn as MCP client
initialize, notifications/initializedSupported
pingSupported when Harn sends it to a server
tools/list, tools/callSupported through mcp_list_tools and mcp_call
resources/list, resources/read, resources/templates/listSupported through resource builtins
prompts/list, prompts/getSupported through prompt builtins
completion/completeNot exposed as a Harn builtin
roots/listSupported; Harn advertises roots.listChanged, serves resolved script/project roots, and exposes the same data through harn.mcp.roots() / mcp_roots()
sampling/createMessageSupported; Harn advertises sampling and dispatches inbound requests to the host bridge (capability="mcp", operation="sample"). Approved requests route to Harn's llm_call and return {role, content, model, stopReason} to the originating server. Without an installed bridge, requests are declined with a structured mcp.samplingDeclined error so servers can fall back gracefully.
elicitation/createSupported; Harn advertises elicitation and dispatches inbound requests to the host bridge (capability="mcp", operation="elicit")
MCP task methods and task-augmented requestsUnsupported; Harn does not advertise task support

Disconnecting

mcp_disconnect(client)

Auto-connection via harn.toml

Instead of calling mcp_connect manually, declare servers in harn.toml. They connect automatically before the pipeline executes and are available through the global mcp dict:

[[mcp]]
name = "filesystem"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]

[[mcp]]
name = "github"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]

[[mcp]]
name = "notion"
transport = "http"
url = "https://mcp.notion.com/mcp"

[[mcp]]
name = "modern"
transport = "http"
url = "https://example.com/mcp"
protocol_mode = "rc"
protocol_version = "DRAFT-2026-v1"

protocol_mode = "rc" opts a server into Harn's draft MCP client profile. RC mode probes stdio servers with server/discover, sends protocol/client metadata on every request, uses stateless Streamable HTTP headers instead of requiring MCP-Session-Id, consumes cache hints from list/read calls, mirrors valid x-mcp-header tool-schema annotations into Mcp-Param-* HTTP headers, and resolves input_required tool results for roots, elicitation, and sampling before retrying the call. Omit protocol_mode for the legacy 2025-11-25 initialize/session behavior.

Lazy boot

Servers marked lazy = true are NOT booted at pipeline startup. They start on the first mcp_call, mcp_ensure_active("name"), or skill activation that declares the server in requires_mcp. This keeps cold starts fast when many servers are declared but only a few are needed per run.

[[mcp]]
name = "github"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
lazy = true
keep_alive_ms = 30_000   # keep the process alive 30s after last release

[[mcp]]
name = "datadog"
command = "datadog-mcp"
lazy = true

Ref-counting: each skill activation or explicit mcp_ensure_active(name) call bumps a binder count. On deactivation or mcp_release(name), the count drops. When it reaches zero, Harn disconnects the server — immediately if keep_alive_ms is absent, or after the window elapses if set.

Explicit control from user code:

// Start the lazy server and hold it open.
let client = mcp_ensure_active("github")
let issues = mcp_call(client, "list_issues", {repo: "burin-labs/harn"})

// Release when done — lets the registry shut it down.
mcp_release("github")

// Inspect current state.
let status = mcp_registry_status()
for s in status {
  log("${s.name}: lazy=${s.lazy} active=${s.active} refs=${s.ref_count}")
}

Server cards (MCP v2.1)

A Server Card is a JSON document that advertises a server's identity, capabilities, and tool catalog without requiring a connection. Harn consumes cards for discoverability and can publish its own when running as an MCP server.

Declare a card source in harn.toml:

[[mcp]]
name = "notion"
transport = "http"
url = "https://mcp.notion.com/mcp"
card = "https://mcp.notion.com/.well-known/mcp-card"

[[mcp]]
name = "local-agent"
command = "my-agent"
lazy = true
card = "./agents/my-agent-card.json"

Fetch it from a pipeline:

// Look up by registered server name.
let card = mcp_server_card("notion")
log(card.description)
for t in card.tools {
  log("- ${t.name}")
}

// Or pass a URL / path directly.
let card = mcp_server_card("./agents/my-agent-card.json")

Cards are cached in-process with a 5-minute TTL, so repeated calls skip the remote fetch. Skill matchers can factor card metadata into scoring without paying connection cost.

Skill-scoped MCP binding

Skills can declare the MCP servers they need via requires_mcp (or the equivalent mcp) frontmatter field. On activation, Harn ensures every listed server is running; on deactivation, it releases them.

skill github_triage {
  description: "Triage GitHub issues and cut fixes",
  when_to_use: "User mentions a GitHub issue or PR by number",
  requires_mcp: ["github"],
  allowed_tools: ["list_issues", "create_pr", "add_comment"],
  prompt: "You are a triage assistant...",
}

When agent_loop activates github_triage, the lazy github MCP server boots (if configured that way) and its process stays alive for as long as the skill is active. When the skill deactivates, the server is released — and if no other skill holds it, the process shuts down (respecting keep_alive_ms).

Transcript events emitted along the way: skill_mcp_bound, skill_mcp_unbound, skill_mcp_bind_failed.

MCP tools in the tool-search index

When an LLM uses tool_search (progressive tool disclosure), MCP tools are auto-tagged with both mcp:<server> and <server> in the BM25 corpus. That means a query like "github" or "mcp:github" surfaces every tool from that server even when the tool's own name and description don't contain the word. Tools returned by mcp_list_tools carry an _mcp_server field that the indexer consumes automatically — no extra wiring needed.

Use them in your pipeline:

pipeline default(task) {
  let tools = mcp_list_tools(mcp.filesystem)
  let content = mcp_call(mcp.filesystem, "read_file", {path: "/tmp/data.txt"})
  log(content)
}

If a server fails to connect, a warning is printed to stderr and that server is omitted from the mcp dict. Other servers still connect normally.

For HTTP MCP servers, Harn can reuse OAuth tokens stored with the CLI:

harn mcp redirect-uri
harn mcp login notion

OAuth client authentication uses one auth table. With no explicit configuration, harn mcp login prefers Client ID Metadata Document (CIMD) registration using Harn's published metadata document at https://harnlang.com/.well-known/oauth-client.json; if the authorization server does not advertise CIMD support, Harn falls back to dynamic client registration.

For a pre-registered client, store the client secret with harn connect api-key and reference it from auth.client_secret_id:

harn connect api-key \
  --connector internal-mcp \
  --secret-id mcp/internal-client-secret
[[mcp]]
name = "internal"
transport = "http"
url = "https://mcp.example.com"
auth = { mode = "byo", client_id = "registered-client", client_secret_id = "mcp/internal-client-secret", scopes = "read:docs write:docs" }

For a static bearer token or API key, reuse the same secret-store path:

harn connect api-key \
  --connector internal-mcp \
  --secret-id mcp/internal-bearer
[[mcp]]
name = "internal"
transport = "http"
url = "https://mcp.example.com"
auth = { mode = "static", secret_id = "mcp/internal-bearer" }

The older top-level client_id, client_secret, scopes, and auth_token fields are accepted for local manifests, but new host-generated config should write the auth = { mode = ... } form so TUI and GUI clients do not need their own OAuth configuration model.

Example: filesystem MCP server

A complete example connecting to the filesystem MCP server, writing a file, and reading it back:

let client = mcp_connect("npx", ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"])

mcp_call(client, "write_file", {path: "/tmp/hello.txt", content: "Hello from Harn!"})
let content = mcp_call(client, "read_file", {path: "/tmp/hello.txt"})
log(content)

let entries = mcp_call(client, "list_directory", {path: "/tmp"})
log(entries)

mcp_disconnect(client)

MCP server (exposing Harn as an MCP server)

Harn pipelines can expose tools, resources, resource templates, and prompts as an MCP server. This lets Claude Desktop, Cursor, or any MCP client call into your Harn code.

Defining tools

Use tool_registry() and tool_define() to create tools, then register them with mcp_tools():

pipeline main(task) {
  var tools = tool_registry()

  tools = tool_define(tools, "greet", "Greet someone", {
    parameters: {name: "string"},
    handler: { args -> "Hello, ${args.name}!" }
  })

  tools = tool_define(tools, "search", "Search files", {
    parameters: {query: "string"},
    handler: { args -> "results for ${args.query}" },
    annotations: {
      title: "File Search",
      readOnlyHint: true,
      destructiveHint: false
    }
  })

  mcp_tools(tools)
}

For large connector packages, use the compact governed Code Mode profile from std/composition:

import { composition_mcp_tools } from "std/composition"

pipeline main(task) {
  mcp_tools(composition_mcp_tools())
}

This exposes harn.code.search_examples, harn.code.generate_harn_api, and harn.code.execute_composition. The executor still runs through Harn's read-only composition path and returns the reduced snippet result by default instead of an opaque "execute code" result. Hybrid servers can call composition_mcp_tools(existing_registry) to expose ordinary tools and the compact Code Mode profile together.

Defining resources and prompts

pipeline main(task) {
  // Static resource
  mcp_resource({
    uri: "docs://readme",
    name: "README",
    text: "# My Agent\nA demo MCP server."
  })

  // Dynamic resource template
  mcp_resource_template({
    uri_template: "config://{key}",
    name: "Config Values",
    handler: { args -> "value for ${args.key}" }
  })

  // Prompt
  mcp_prompt({
    name: "review",
    description: "Code review prompt",
    arguments: [{name: "code", required: true}],
    handler: { args -> "Please review:\n${args.code}" }
  })
}

Running as an MCP server

harn serve mcp agent.harn

harn serve mcp auto-detects whether the script exposes its surface through pub fn exports or through the mcp_tools(...) / mcp_resource(...) / mcp_prompt(...) registration builtins shown above and serves the appropriate one over stdio or Streamable HTTP. All print/println output goes to stderr when stdio is the MCP transport. The server supports the 2025-11-25 MCP protocol version on both transports.

List endpoints are cursor-paginated. tools/list, resources/list, resources/templates/list, and prompts/list return up to 100 entries by default and include nextCursor when another page is available. Set HARN_MCP_LIST_PAGE_SIZE to a positive integer to change the per-page limit.

MCP server support matrix

Harn's MCP servers implement the core tool/resource/prompt path and explicitly reject latest-spec features that are out of scope for this release with a JSON-RPC error containing error.data.type = "mcp.unsupportedFeature". Servers advertise resources.subscribe when resources are present. The orchestrator MCP server wires harn://topic/* resources to EventLog-backed notifications/resources/updated; script-driven servers accept subscriptions for registered resource URIs.

Method or featureHarn as MCP server
initialize, notifications/initialized, pingSupported
logging/setLevelAccepted
tools/list, tools/callSupported
notifications/progress, notifications/cancelledSupported for long-running tool calls
resources/list, resources/read, resources/templates/listSupported for registered entries, cards, package context, prompt sources, and orchestrator EventLog resources
resources/subscribe, resources/unsubscribeSupported for registered resource URIs; orchestrator topic resources emit update notifications
prompts/list, prompts/getSupported for registered prompts
completion/completeSupported for prompt arguments and resource template arguments
roots/listExplicitly unsupported; client-side roots are not served by Harn
sampling/createMessageServer-initiated sampling against the connected client is not currently emitted by the orchestrator-mode catalog; Harn declares the sampling capability when acting as a client (see the client matrix).
elicitation/createSupported outbound from script-driven handlers via mcp_elicit(...); inbound client requests to the server are rejected with an explicit unsupported-feature error
tasks/get, tasks/result, tasks/list, tasks/cancelSupported for task-augmented orchestrator tool calls
tools/call with params.taskSupported for tools that advertise optional task execution; rejected with -32602 for non-taskable tools

Publishing a server card

Attach a Server Card so clients can discover your server's identity and capabilities before connecting:

harn serve mcp agent.harn --card ./card.json

The card JSON is embedded in the initialize response's serverInfo.card field and also exposed as a read-only resource at well-known://mcp-card. Minimal shape:

{
  "name": "my-agent",
  "version": "1.0.0",
  "description": "Short one-line summary shown in pickers.",
  "protocolVersion": "2025-11-25",
  "capabilities": { "tools": true, "resources": false, "prompts": false },
  "tools": [
    {"name": "greet", "description": "Greet someone by name"}
  ]
}

--card also accepts an inline JSON string for ad-hoc publishing: --card '{"name":"demo","description":"…"}'.

Configuring in Claude Desktop

Add to claude_desktop_config.json:

{
  "mcpServers": {
    "my-agent": {
      "command": "harn",
      "args": ["serve", "mcp", "agent.harn"]
    }
  }
}

ACP (Agent Client Protocol)

ACP lets host applications and local clients use Harn as a runtime backend. Communication is JSON-RPC 2.0 over stdin/stdout or WebSocket text frames.

Bridge-level tool gates and daemon idle/resume notifications are documented in Bridge protocol.

Running the ACP server

harn serve acp pipeline.harn      # execute a specific pipeline per prompt
harn serve acp --transport websocket --bind 127.0.0.1:8789 pipeline.harn

The packaged ACP adapter is exposed through harn serve acp. Pass --api-key <key> or set HARN_SERVE_API_KEY to require clients to complete ACP authenticate before creating sessions. Harn advertises the configured methods in initialize.authMethods; unauthenticated protected requests return ACP auth_required with the same method details. WebSocket clients may also send the key as Authorization: Bearer <key> or X-API-Key during the upgrade; header authentication pre-authorizes that ACP connection while headerless clients can still use the in-band ACP authenticate method.

Protocol overview

The ACP server supports these JSON-RPC methods:

MethodDescription
initializeHandshake with capabilities
authenticateAuthenticate the ACP connection when authMethods is non-empty
session/newCreate a new session (returns session ID)
session/forkFork an existing session into an independent branch
session/listList active sessions known to the ACP adapter
session/promptSend a prompt to the agent for execution
session/injectAccept a pending user message for a running turn and return its messageId
session/revoke_injectRevoke a pending injected user message before delivery
session/replace_injectReplace pending injected content without changing its queue position
session/remindQueue a typed system reminder for the active agent loop
session/pending_injectionsInspect pending bridge user-message and reminder injections
session/revoke_reminderRevoke a pending bridge reminder before delivery
session/cancelCancel the currently running prompt
session/cancel_tool_callCancel one in-flight tool call without tearing down the session
session/truncateTruncate the session transcript to a requested prefix
session/rollbackRoll back the last completed prompt turn, including tracked filesystem pre-images
session/redoReapply the most recent rollback while the redo stack is still valid
session/closeClose a session and cancel any active prompt
session/stopDeprecated alias for session/close
session/set_modeSwitch the active session mode
session/set_config_optionSwitch a preferred ACP session config option (mode, model, thought_level, or budget)
harn.session_workspace_rootsReturn the live session's primary workspace anchor and mounted roots
harn.session_add_rootMount an additional root into the live session
harn.session_reanchorReplace the live session's primary workspace anchor
harn.session_timeline.queryReturn the Harn-owned redacted session timeline snapshot
harn.session_timeline.subscribeSubscribe to newly appended timeline events
harn.session_timeline.unsubscribeStop a timeline subscription
workflow/signalEnqueue a workflow signal message in the current session workspace
workflow/queryRead a named workflow query value from the current session workspace
workflow/updateSend a workflow update request and wait for a response
workflow/pauseMark a workflow paused and enqueue a control message
workflow/resumeMark a workflow resumed and enqueue a control message

workflow/* methods also accept the harn.workflow.* aliases. They expect workflowId, plus name where applicable, optional payload, and timeoutMs for workflow/update. These methods resolve workflow state against the session's configured working directory, so they operate on the same durable .harn/workflows/<workflowId>/state.json tree that the in-language builtins use.

During initialize, Harn mirrors the public agentCapabilities object from upstream ACP: loadSession, promptCapabilities, mcpCapabilities, session.inject, and the canonical sessionCapabilities.close and sessionCapabilities.list flags are advertised in their upstream locations. Harn advertises session.inject.modes = ["queue", "steer"] and session.inject.pending.replace = true. Harn also advertises session.remind.modes = ["interrupt_immediate", "finish_step", "audit_only"] and session.remind.pending = {list: true, revoke: true} for host-side system-reminder queue controls. Harn advertises sessionCapabilities.rollback and sessionCapabilities.redo for the completed-turn checkpoint stack; both methods return typed success/failure statuses and reject while a prompt is active. Harn-only methods such as session/fork remain documented extensions instead of being inserted into upstream sessionCapabilities.

session/rollback and session/redo are Harn-owned session primitives. Harn moves the transcript to the checkpoint boundary and, when hostlib filesystem snapshots are available, the ACP adapter restores the matching file pre-images in the same request. Rollback captures redo filesystem snapshots before restoring the older state. Redo is invalidated by a new transcript mutation, workspace reanchor/root change, staged filesystem commit/discard, or direct tool-call restore.

Example response:

{
  "status": "rolled_back",
  "checkpointId": "turn_...",
  "beforeMessageCount": 1,
  "afterMessageCount": 2,
  "fsSnapshotIds": ["tc_42"],
  "redoFsSnapshotIds": ["turn_...:redo:tc_42"],
  "fsRestores": [
    {
      "snapshotId": "tc_42",
      "restoredPaths": ["notes.md"],
      "skippedPathsWithReasons": []
    }
  ]
}

Session workspace anchors

The Harn workspace-anchor extension exposes the same live VM session state used by agent_session_workspace_anchor, agent_session_add_root, and agent_session_reanchor. All three methods require sessionId/session_id and return the current workspaceAnchor.

harn.session_workspace_roots is a read path that also seeds a missing anchor from the live session's cwd, which preserves compatibility with sessions opened before first-class anchors were attached.

{
  "method": "harn.session_workspace_roots",
  "params": { "sessionId": "sess_abc" }
}

harn.session_add_root accepts path (or root), optional mountMode (read_only, extend, or sandboxed), and optional reason. It validates that the mounted path exists and is readable before mutating the session, records the canonical RootMounted transcript event, and clears session-local permission grants.

{
  "method": "harn.session_add_root",
  "params": {
    "sessionId": "sess_abc",
    "path": "/workspace/tools",
    "mountMode": "extend"
  }
}

harn.session_reanchor accepts path (or primary) plus optional reason, carryTranscript, and compact. The live ACP path currently supports the default carryTranscript: true and compact: false shape; richer fork/compact handoffs still belong to agent_session_reanchor until ACP has a host-visible session handoff envelope.

{
  "method": "harn.session_reanchor",
  "params": {
    "sessionId": "sess_abc",
    "path": "/workspace/next"
  }
}

Session timeline extension

harn.session_timeline.query projects existing Harn observability records into one client-facing timeline shape. The query accepts sessionId/session_id, optional runId/run_id, optional runPath/run_path, optional projectId/project_id, optional fromCursor, and optional limit. The response includes:

  • schemaVersion
  • cursor.topics, a per-event-log-topic cursor map for later queries
  • nodes, ordered timeline entries with stable id, parentId, children, category, kind, status, redacted attributes, source references, and causal links

Span nodes come from run trace spans when a run record is supplied to the VM API or when ACP can load a persisted run record from runPath or from runId in the default Harn run directory. ACP queries also use the active event log and include session agent events plus channel lifecycle/audit events. Channel match nodes link back to the matching channel emit node through links.kind = "channel_emit". Batched channel match nodes also include links.kind = "channel_batch_member" entries for the recorded constituent event ids.

{
  "method": "harn.session_timeline.query",
  "params": {
    "sessionId": "sess_abc",
    "runId": "run_123",
    "fromCursor": {
      "topics": {
        "observability.agent_events.sess_abc": 42
      }
    }
  }
}

harn.session_timeline.subscribe accepts the same query fields plus an optional subscriptionId. It returns {subscriptionId, updateMethod} and then emits harn.session_timeline.update notifications:

{
  "method": "harn.session_timeline.update",
  "params": {
    "subscriptionId": "timeline-1",
    "update": {
      "schemaVersion": 1,
      "cursor": {"topics": {"observability.agent_events.sess_abc": 43}},
      "node": {"id": "event:observability.agent_events.sess_abc:43"}
    }
  }
}

Use harn.session_timeline.unsubscribe with subscriptionId to stop the live stream. Closing a session also cancels subscriptions scoped to that session.

Session forking

session/fork promotes Harn's runtime transcript branching to a host-visible ACP method. The request shape is:

{
  "session_id": "sess_parent",
  "keep_first": 3,
  "id": "sess_branch",
  "branch_name": "left"
}
  • session_id is required and identifies the source session to fork.
  • keep_first is optional; when present Harn uses agent_session_fork_at(session_id, keep_first, id?).
  • Without keep_first, Harn uses agent_session_fork(session_id, id?).
  • id is optional; when omitted Harn mints a fresh session id.
  • branch_name is optional session metadata that Harn mirrors into the forked session's title and _meta.branch_name.

Successful responses return the new branch id plus fork metadata:

{
  "sessionId": "sess_branch",
  "state": "forked",
  "parent_id": "sess_parent",
  "branched_at": 3
}

When a fork is created, Harn also emits a session/update notification with sessionUpdate: "session_info_update" and _meta.state = "forked" so ACP hosts can render branch-aware session UIs without scraping text output. The forked session gets its own stream; subscriber sinks and in-flight prompt state are not copied from the parent.

Session modes

Harn exposes ACP session config options and the legacy session-modes field from the same mode catalog. Clients that understand configOptions should use it; Harn keeps modes available for clients still on session/set_mode. session/new and session/load return both shapes:

{
  "configOptions": [
    {
      "id": "mode",
      "name": "Session Mode",
      "category": "mode",
      "type": "select",
      "currentValue": "ask",
      "options": [
        { "value": "ask",       "name": "Ask",       "description": "..." },
        { "value": "architect", "name": "Architect", "description": "..." },
        { "value": "code",      "name": "Code",      "description": "..." },
        { "value": "shadow",    "name": "Shadow",    "description": "..." }
      ]
    },
    {
      "id": "model",
      "name": "LLM Model",
      "category": "model",
      "type": "select",
      "currentValue": "@inherit",
      "options": [
        { "value": "@inherit",         "name": "Inherit ambient default", "description": "..." },
        { "value": "claude-sonnet-4-6", "name": "claude-sonnet-4-6 (anthropic/claude-sonnet-4-6)", "description": "tier: frontier" }
      ]
    },
    {
      "id": "thought_level",
      "name": "Thought Level",
      "category": "model",
      "type": "select",
      "currentValue": "@inherit",
      "options": [
        { "value": "@inherit", "name": "Inherit script default", "description": "..." },
        { "value": "auto",     "name": "Auto",   "description": "..." },
        { "value": "off",      "name": "Off",    "description": "..." },
        { "value": "minimal",  "name": "Minimal", "description": "..." },
        { "value": "low",      "name": "Low",    "description": "..." },
        { "value": "medium",   "name": "Medium", "description": "..." },
        { "value": "high",     "name": "High",   "description": "..." },
        { "value": "xhigh",    "name": "Extra High", "description": "..." }
      ]
    },
    {
      "id": "budget",
      "name": "Call Budget",
      "category": "_harn_budget",
      "type": "select",
      "currentValue": "@inherit",
      "options": [
        { "value": "@inherit", "name": "Inherit server default", "description": "..." },
        { "value": "off", "name": "No session budget", "description": "..." },
        { "value": "{\"llm_cost_usd\":0.05,\"llm_tokens\":200000}", "name": "$0.05 and 200k tokens", "description": "..." }
      ]
    }
  ],
  "modes": {
    "currentModeId": "ask",
    "availableModes": [
      { "id": "ask",       "name": "Ask",       "description": "..." },
      { "id": "architect", "name": "Architect", "description": "..." },
      { "id": "code",      "name": "Code",      "description": "..." },
      { "id": "shadow",    "name": "Shadow",    "description": "..." }
    ]
  }
}

Mode semantics:

  • ask — conservative default, mapped to Harn's act_with_approval autonomy tier.
  • architect — read-only planning mode. Builtins that mutate the workspace, execute processes, or hit the network are rejected by the VM policy gate before they run. Maps to Harn's suggest autonomy tier.
  • code — full tool access. Harn leaves host/runtime capability resolution authoritative instead of installing an extra ceiling. Maps to act_auto.
  • shadow — proposal-only mode mapped to Harn's shadow autonomy tier. Side effects are blocked before they touch the workspace.

Preferred mode switching:

{
  "jsonrpc": "2.0",
  "id": 17,
  "method": "session/set_config_option",
  "params": { "sessionId": "sess_abc", "configId": "mode", "value": "architect" }
}

Legacy mode switching:

{
  "jsonrpc": "2.0",
  "id": 17,
  "method": "session/set_mode",
  "params": { "sessionId": "sess_abc", "modeId": "architect" }
}

session/set_config_option responds with the complete configOptions state. Both switching methods emit session/update notifications with sessionUpdate: "current_mode_update" and sessionUpdate: "config_option_update" when the selected mode changes. Re-setting the same mode is a no-op (ack only, no notification). session/fork carries the parent's active mode over to the new branch and echoes it back on the fork response.

The selected mode is enforced for the next session/prompt: while the prompt runs, the matching capability policy is pushed onto the VM execution stack and popped when the prompt completes. Harn derives that policy from the same AutonomyTier machinery used by trigger dispatch, so ACP sessions and runtime autonomy stay aligned. The conformance case acp_architect_mode_blocks_destructive_writes_in_prompt locks this behavior end-to-end.

Session model pin

session/set_config_option(configId="model") pins an LLM model selector on the session so subsequent llm_call invocations without an explicit model: option resolve to the pinned value instead of the HARN_LLM_MODEL / providers.toml default. This is the wire surface behind editor /model commands (Crush, OpenCode <leader>m, Codex /model) and replaces the "the change takes effect after /clear" workaround.

ACP clients can request the full picker source of truth with the Harn extension method _harn/providerCatalog. The result is the provider catalog v2 artifact returned by harn providers export, normalized through the runtime's effective overlays. Clients should use that catalog for model display names, aliases, auth env names, local/cloud classification, context windows, tool-support hints, availability, and pricing instead of vendoring picker rows.

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "session/set_config_option",
  "params": {
    "sessionId": "sess_abc",
    "configId": "model",
    "value": "claude-sonnet-4-6"
  }
}

Accepted selector forms:

  • a known alias from the llm.toml catalog (e.g. claude-sonnet-4-6)
  • an explicit provider:model or provider/model pair where the provider is in providers.toml
  • a model id present in the catalog (model_catalog_entry)

Setting the value to the sentinel "@inherit" (also accepted: empty string) clears the pin and reverts the session to the ambient default. The ACP wire surface is intentionally curated; scripts that need ad-hoc selectors still pass model: directly to llm_call.

Per-call options always win over the pin — invoking llm_call(..., {model: "..."}) ignores the session pin so prompts that deliberately route to a different model aren't silently rebound. The existing conversation buffer, transcript metadata, and memory context are untouched by a model swap; only the resolver for the next prompt's default model changes. Pinning rejects unregistered providers with a -32602 error tagged invalid_model.

Notification semantics match mode: a session/update with sessionUpdate: "config_option_update" is emitted whenever the pin actually changes (re-pinning to the same selector is a silent ack). session/fork carries the parent's pin over to the new branch (matching how tool_format and the session-level system prompt inherit), so a branch starts with the same effective model as the parent. Setting a different pin on the child stays local.

Session thought level

session/set_config_option(configId="thought_level") pins Harn's provider-aware reasoning policy on the session. The value is intentionally higher-level than any single provider API: Harn lowers it to the selected route's native thinking representation before the provider call.

{
  "jsonrpc": "2.0",
  "id": 43,
  "method": "session/set_config_option",
  "params": {
    "sessionId": "sess_abc",
    "configId": "thought_level",
    "value": "high"
  }
}

Accepted values are auto, off, minimal, low, medium, high, and xhigh; none, disabled, no_think, and nothink are accepted aliases for off. Setting "@inherit" clears the pin.

Lowering examples:

  • OpenAI reasoning routes receive reasoning_effort / typed thinking: {mode: "effort", level: ...} where supported.
  • Gemini routes receive typed Harn thinking that the provider adapter lowers to thinkingBudget, dynamic thinking, or the supported compatibility shape.
  • Anthropic routes use adaptive thinking where a model requires it and budgeted extended thinking where that is still supported.
  • Qwen-style local/open-compatible routes use thinking: {mode: "disabled"} for off, which triggers Harn's capability-driven /no_think injection or chat_template_kwargs.enable_thinking=false where the transport supports it.

Per-call options still win: llm_call(..., {thinking: ...}) and llm_call(..., {reasoning_effort: ...}) bypass the session thought pin. session/fork carries the parent's thought pin to the child, and later changes remain branch-local.

Session budget

AcpServerConfig::with_budget(BudgetSpec) installs an inherited resource budget for every ACP session. The guard is applied around each session/prompt turn on the same runtime thread that executes the VM, using the same BudgetSpec/call_budget path as HTTP @budget(...) dispatch. Use with_llm_cost_budget(...) or with_llm_token_budget(...) when the embedder only needs an LLM-specific ceiling.

Clients can re-arm or disable the session budget without restarting the server by setting configId: "budget":

{
  "jsonrpc": "2.0",
  "id": 44,
  "method": "session/set_config_option",
  "params": {
    "sessionId": "sess_abc",
    "configId": "budget",
    "value": "{\"llm_cost_usd\":0.05,\"llm_tokens\":200000}"
  }
}

The value is a string because ACP config options are string selectors. Harn accepts @inherit to use the server default, off to disable the session budget, or a compact JSON object with any of llm_cost_usd, llm_tokens, mcp_calls, and pg_queries. Exhaustion uses the existing budget error and agent-event path, so hosts that already render _harn/agentEvent budget_exhausted updates do not need a new event type.

Queued user messages and reminders during agent execution

ACP hosts can inject user follow-up messages while an agent is running. Harn owns the delivery semantics inside the runtime so product apps do not need to reimplement queue/orchestration logic. Clients call session/inject, which responds as soon as Harn accepts the pending message and returns an agent-owned messageId. Delivery is later echoed as a session/update notification with sessionUpdate: "user_message" and the same messageId. User-message methods remain user-role only; ambient or system-style context uses session/remind.

Relevant methods and notifications:

  • session/inject
  • session/revoke_inject
  • session/replace_inject
  • session/remind
  • session/pending_injections
  • session/revoke_reminder
  • session/update with worker_update content for delegated worker lifecycle events

Pending inject payload shape:

{
  "sessionId": "sess_abc",
  "mode": "steer",
  "content": [
    {
      "type": "text",
      "text": "Please stop editing that file and explain first."
    }
  ]
}

mode: "steer" delivers at the next safe operation boundary. mode: "queue" delivers at end-of-interaction. While the message is pending, callers can revoke it with session/revoke_inject or replace only its content with session/replace_inject; replacement preserves the original messageId, mode, and queue position. Revoke is idempotent for known revoked ids. Revoke or replace after delivery returns an already_delivered error, and unknown ids return unknown_message_id.

Reminder payload shape:

{
  "body": "The host noticed new repository instructions; re-read AGENTS.md.",
  "tags": ["host_context"],
  "dedupe_key": "repo-instructions",
  "ttl_turns": 2,
  "role_hint": "system",
  "mode": "finish_step",
  "_meta": {
    "harn": {
      "source": "workspace-index"
    }
  }
}

Reminder mode values:

  • interrupt_immediate
  • finish_step
  • audit_only

Pending inject runtime behavior:

  • steer: deliver after the current operation finishes.
  • queue: deliver at the end of the current interaction.
  • Pending injects are session-scoped and survive session/cancel and suspend/resume bridge replacement; clients that want to clear them should revoke them before cancelling.
  • Pending, revoked, and pre-replacement content never enter transcript replay. Replay shows only delivered user_message updates.

Reminder runtime behavior:

  • session/remind: queues a typed system reminder instead of a user-role message. Malformed reminder payloads are rejected with HARN-RMD-002. session/inject never creates reminders.
  • interrupt_immediate: drain at the next eligible bridge checkpoint, including pre_tool_dispatch when the reminder arrives between LLM output and tool dispatch.
  • finish_step: drain at the next iteration boundary so the model sees the reminder on the next prompt build.
  • audit_only: drain at loop_exit for transcript audit only; no later model call sees it.
  • session/pending_injections: returns {pendingCount, injections} for the bridge queue. Rows are FIFO ordered and include kind: "user" or kind: "reminder" plus stable messageId / reminderId identifiers.
  • session/revoke_reminder: removes a queued reminder by reminderId before a checkpoint drains it. Revoke is idempotent for known revoked ids; races after delivery return already_delivered, and unknown ids return unknown_reminder_id.
  • session/cancel_tool_call: abort one in-flight tool call (e.g. user clicked stop on a runaway git push --force) without closing the session. Params: sessionId, toolCallId, optional reason (surfaced to the model), and optional injectReminder: bool = true (queues a system reminder so the model knows it was stopped by the host). The response shape is {status: "cancelled" | "already_cancelled" | "not_found", callId, tool}. The cancelled call returns to the loop as a status: "cancelled" tool_result so the model can distinguish "the host stopped me" from "the tool errored." Pairs with the in-VM cancel_in_flight_tool_call Harn builtin — both surfaces share the registry.
  • Worker lifecycle updates are emitted as structured session/update payloads with worker id/name, status, lineage metadata, artifact counts, transcript presence, snapshot path, execution metadata, child run ids/paths, lifecycle summaries, and audit-session metadata when applicable. Hosts can render these as background task notifications instead of scraping stdout.
  • Bridge-mode logs also stream boot timing records (ACP_BOOT with compile_ms, vm_setup_ms, vm_baseline_cache, and execute_ms) and live span_end duration events while a prompt is still running, so hosts do not need to wait for the final stdout flush to surface basic timing telemetry. File-backed prompts keep the compile cache and VM baseline cache separate: repeated turns reuse stable stdlib/project/source setup, then instantiate a clean execution VM for prompt globals, output, bridge handles, tasks, cancellation, sync primitives, shared state, and tracing.

Typed pipeline returns (Harn → ACP boundary)

Pipelines are what produce ACP events (agent_message_chunk, tool_call, tool_call_update, plan, sessionUpdate). Declaring a return type on a pipeline turns the Harn→ACP boundary into a type-checked contract instead of an implicit shape that only the bridge validates:

type PipelineResult = {
  text: string | nil,
  events: list<dict> | nil,
}

pub pipeline ghost_text(task) -> PipelineResult {
  return {
    text: "hello",
    events: [],
  }
}

The type checker verifies every return <expr> against the declared type, so drift between pipeline output and bridge expectation is caught before the Swift/TypeScript bridge ever sees the message.

Public pipelines without an explicit return type emit the pipeline-return-type lint warning. Explicit return types on the Harn→ACP boundary will be required in a future release; the warning is a one-release deprecation window.

Well-known entry pipelines (default, main, auto, test) are exempt from the warning because their return value is host-driven, not consumed by a protocol bridge.

Canonical ACP envelope types are provided as Harn type aliases in std/acpSessionUpdate, AgentMessageChunk, ToolCall, ToolCallUpdate, Plan, and Handoff — and can be used directly as pipeline return types so a pipeline's contract matches the ACP schema byte-for-byte.

std/plan provides Harn-owned emit_plan and update_plan tools plus a harn.plan.v1 artifact shape. ACP keeps emitting the standard sessionUpdate: "plan" entries array; when the source is a Harn plan artifact, the update also carries harnPlan with the normalized steps, assumptions, open questions, verification commands, and approval state.

When a workflow emits a typed handoff artifact, ACP also mirrors it as a structured session/update with sessionUpdate: "handoff", so hosts can show handoff lifecycle entries without scraping transcript prose.

Security notes

Remote MCP OAuth

harn mcp login stores remote MCP OAuth tokens in the local OS keychain for standalone CLI reuse. Treat that as durable delegated access:

  • prefer the narrowest scopes the server supports
  • treat configured client_secret values as secrets
  • expect HTTP MCP servers to publish RFC 9728 protected-resource metadata and OAuth/OIDC authorization-server metadata; Harn validates issuer binding before storing or refreshing tokens
  • use local environment variables or client-managed secrets for stdio MCP servers; the HTTP OAuth discovery flow does not apply to local stdio launches
  • review remote MCP capabilities before wiring them into autonomous workflows

Safer write defaults

Harn now propagates mutation-session audit metadata through workflow runs, delegated workers, and bridge tool gates. Recommended host defaults remain:

  • proposal-first application for direct workspace edits
  • worktree-backed execution for autonomous/background workers
  • explicit approval for destructive or broad-scope mutation tools

Bridge mode

ACP internally uses Harn's host bridge so the host can retain control over tool execution while Harn still owns agent/runtime orchestration.

Unknown builtins are delegated to the host via builtin_call JSON-RPC requests. This enables the host to provide filesystem access, editor integration, or other capabilities that Harn code can call as regular builtins.

A2A (Agent-to-Agent Protocol)

A2A exposes exported Harn functions as a peer-agent HTTP server that other agents can interact with. The server implements A2A protocol version 0.3.0 and uses the shared harn-serve dispatch core.

Running the server

harn serve a2a agent.harn             # explicit A2A
harn serve agent.harn                 # legacy shorthand for A2A
harn serve a2a --port 3000 agent.harn

Agent card

The server publishes an A2A AgentCard at GET /.well-known/agent-card.json, with compatibility aliases at GET /.well-known/a2a-agent, GET /.well-known/agent.json, and GET /agent/card. The card advertises each exported pub fn as an A2A skill through preferredTransport, additionalInterfaces, default input/output modes, capabilities, and security declarations. Set --card-signing-secret or HARN_SERVE_A2A_CARD_SECRET to attach an HS256 signature envelope to the card.

Task submission

Submit a task with a JSON-RPC request:

POST /
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": "task-1",
  "method": "message/send",
  "params": {
    "function": "triage",
    "configuration": {"blocking": true},
    "message": {
      "role": "user",
      "parts": [{"type": "text", "text": "Analyze this codebase"}]
    }
  }
}

Use message/send with configuration.returnImmediately = true for an asynchronous task, configuration.blocking = true for a blocking task, and message/stream or tasks/resubscribe for SSE task updates. Harn preserves the legacy a2a.*, tasks/send, and tasks/send_and_wait names for one minor cycle and marks those HTTP responses with a Deprecation header. If the served file exports exactly one function, the function selector can be omitted.

Canonical HTTP+JSON/REST binding

The same task lifecycle is also exposed over the canonical A2A 0.3.0 HTTP+JSON/REST binding under /v1. The agent card advertises this transport in additionalInterfaces alongside the JSON-RPC entry, e.g.

"additionalInterfaces": [
  {"url": "https://agent.example", "transport": "JSONRPC"},
  {"url": "https://agent.example/v1", "transport": "HTTP+JSON"}
]

REST endpoints (per the spec's AIP-136 custom-method form):

MethodPathBody
POST/v1/message:sendMessageSendParams
POST/v1/message:streamMessageSendParams (returns SSE)
GET/v1/tasks/{id}
POST/v1/tasks/{id}:cancel
POST/v1/tasks/{id}:subscribe— (returns SSE)
POST/v1/tasks/{id}/pushNotificationConfigsPushNotificationConfig
GET/v1/tasks/{id}/pushNotificationConfigs
GET/v1/tasks/{id}/pushNotificationConfigs/{configId}
DELETE/v1/tasks/{id}/pushNotificationConfigs/{configId}
GET/v1/card— (authenticated extended card)

REST responses return the bare result payload (e.g. a Task object) rather than a JSON-RPC envelope. Errors are returned with an HTTP 4xx/5xx status and a JSON-RPC error body. The legacy non-canonical paths (/message/send, /message/stream, /tasks/send, /tasks/send_and_wait, /tasks/cancel, /tasks/resubscribe) keep working for one minor cycle and emit a Deprecation: true header plus a Warning: 299 … advisory that points to the canonical replacement.

Task status

Check the status of a submitted task:

POST /
Content-Type: application/json

{"jsonrpc":"2.0","id":"get-1","method":"tasks/get","params":{"id":"<task-id>"}}

Task states follow the A2A 0.3.0 lifecycle:

  • Active: submitted, working.
  • Pause states (non-terminal): input-required is set while a HITL primitive (ask_user, request_approval, dual_control, escalate_to) is suspended on a waitpoint; the SSE stream carries a paired hitl event with the request payload, then flips back to working when the response arrives. auth-required is set when a downstream call inside the script raises an auth-classified error (e.g. an HTTP 401 from an LLM provider); the client is expected to re-authenticate and resubscribe.
  • Terminal: completed, failed, cancelled, rejected. rejected is returned synchronously when the dispatch core's AuthPolicy denies the caller before any script work runs (the caller cannot recover by retrying with the same credentials — policy/credentials must change).

Completed task payloads also include metadata.handoff_ids and metadata.handoffs when the served function returned typed handoff artifacts, so remote personas can consume the handoff artifact directly instead of replaying the source transcript.

Vendor workflow control methods

In addition to the standard task lifecycle calls, Harn's A2A adapter accepts vendor JSON-RPC methods for durable workflow control:

  • a2a.WorkflowSignal or harn.workflow.signal
  • a2a.WorkflowQuery or harn.workflow.query
  • a2a.WorkflowUpdate or harn.workflow.update
  • a2a.WorkflowPause or harn.workflow.pause
  • a2a.WorkflowResume or harn.workflow.resume

These methods expect workflowId, optional name, optional payload, and timeoutMs for updates. They resolve workflow state relative to the served pipeline's workspace, which makes them compatible with the same persisted workflow mailbox used by the Harn builtins and ACP surface.