Outbound workflow server
harn-serve is the shared outbound-server crate for exposing Harn workflows to
external callers. It contains the local Agents API, MCP, A2A, and ACP adapters
plus the shared dispatch, auth, replay, and export-catalog pieces those
adapters use.
For user-facing protocol examples, start with MCP, ACP, and A2A integration. For a quick status table across entry points, see Protocol support matrix.
The goal is to keep protocol adapters thin:
- load one
.harnmodule and discover its exportedpub fnentrypoints once - lower Harn param and return types into adapter-facing schemas once
- authenticate inbound requests through one normalized auth policy surface
- invoke the same Harn function regardless of transport
- emit the same tracing and trust-graph records regardless of transport
Shared-core responsibilities
The harn-serve crate owns these pieces:
- export catalog loading for
pub fnentrypoints - shared dispatch request/response types
- replay-cache hooks for idempotent replays
- cooperative cancellation wiring into the VM
- normalized auth policy types: API keys HMAC canonical-request signatures OAuth 2.1 claims already validated by the hosting transport
- unified observability: one inbound span per call one trust-graph record per terminal outcome
- common adapter descriptors and transport-specific modules so MCP, A2A, and ACP can layer their own wire protocol without duplicating shared concerns
This keeps adapter tickets focused on protocol mechanics such as discovery documents, streaming, progress notifications, or session semantics.
HTTP response codec
For HTTP-shaped adapters (the local Agents API and any future REST
surface), .harn handlers shape their replies with the http_*
builtins. The handler returns the result of one of these — the
adapter renders it into a real axum::Response:
| Builtin | Result |
|---|---|
http_ok(body) | 200 OK + JSON body |
http_created(body, location?) | 201 Created + optional Location header |
http_no_content() | 204 No Content, body suppressed |
http_error(status, code, message, details?) | error envelope: { code, message, request_id, details? } |
http_stream(source, content_type?) | streamed body, source is a list or channel of chunks |
http_sse(source, retry_ms?) | Server-Sent Events, source is a list or channel |
http_reply(status, body?, headers?) | low-level escape hatch for arbitrary 1xx-5xx codes |
Plain values returned from a handler still work — they degrade to
200 OK with Content-Type: application/json. Untagged dicts that
happen to have a status key are not picked up; only the tagged
record from the builtins above is.
Adapter-level dispatch failures (auth, scope, validation, execution)
render through the same envelope, so callers see one consistent
error shape regardless of who raised the error. The request_id
field is filled in by the codec from the inbound request and is
always present.
Picking an adapter
Choose the adapter based on the caller's mental model, not by protocol popularity alone.
Local agents API
Choose the local Agents API when a client wants an OpenAPI-described HTTP surface instead of a protocol-specific JSON-RPC transport.
Run it with:
harn serve api agent.harn
harn serve api --bind 127.0.0.1:8787 agent.harn
harn serve api --api-key "$HARN_KEY" agent.harn
Behavior today:
- serves
GET /openapi.json,GET /health,GET /version, and/v1/* - creates ACP-backed sessions and runs prompts as Tasks
- streams session, task, tool, and permission updates over SSE
- forwards ACP
session/request_permissionand Harn HITL responses through the existing runtime path so decisions remain transcript and replay visible - exposes local runtime, capability, provider-catalog, tool-registry, workspace, and UTF-8 file helpers for generated SDKs
- supports the same API-key, HMAC, and TLS listener settings as the other HTTP adapters
MCP
Choose MCP when the caller wants a tool surface.
Typical fit:
- IDEs that can mount tools
- agent frameworks that already speak MCP
- clients that expect
tools/listandtools/call
Mapping:
- each exported
pub fnbecomes a tool - Harn type annotations become tool input/output schemas
- replay keys map naturally to idempotent tool invocations
Run it with:
harn serve mcp server.harn
harn serve mcp --transport http server.harn
harn serve mcp --transport http --tls edge --bind 127.0.0.1:8765 server.harn
harn serve mcp --transport http --tls self-signed-dev --bind 127.0.0.1:8765 server.harn
harn serve mcp --transport http --cert certs/prod.pem --key certs/prod-key.pem server.harn
Behavior today:
- stdio transport for local subprocess-style MCP clients
- Streamable HTTP
POST/GETendpoint at--path - legacy SSE compatibility endpoints at
--sse-pathand--messages-path - TLS listener modes:
plainfor intentional HTTP,edgewhen public TLS is terminated by a proxy/load balancer,self-signed-devfor local HTTPS testing, and PEM cert/key files for in-process HTTPS termination - progress notifications when the caller provides
_meta.progressToken - cooperative cancel propagation from
notifications/cancelled - Harn-native context discovery:
package source, nearest
harn.toml, README, and.harn.promptfiles appear throughresources/list,resources/read,resources/templates/list,prompts/list, andprompts/get completion/completefor file-backed prompt arguments and package/prompt resource template arguments- HTTP auth hooks built on the shared
AuthPolicysurface: API keys HMAC canonical-request signatures OAuth 2.1 claims injected by a hosting transport
Site
Choose harn serve site when the caller is a plain HTTP client — a
browser, a webhook sender, a REST consumer — and you want the script to
answer its own routes rather than a fixed protocol surface.
Each routed pub fn receives a request dict and returns a value or an
http_* response envelope:
@route("POST", "/users/{id}")
pub fn update_user(req: dict) -> dict {
return http_ok({ "id": req.path_params.id, "body": json_parse(req.body) })
}
// The `handler_*` convention needs no attribute: `handler_health` mounts
// at GET|POST /health; a bare `handler` mounts at the site root `/`.
pub fn handler_health(req: dict) -> dict {
return http_ok({ "ok": true })
}
@route("/path") (one argument) defaults the method to GET;
@route("ANY", "/path") (or "*") answers every method. A pub fn with
neither a @route attribute nor a handler_ name stays dispatch-only —
still callable by name from the other adapters, but no bare path reaches
it.
Run it with:
harn serve site server.harn
harn serve site --bind 127.0.0.1:8788 server.harn
harn serve site --api-key "$HARN_SERVE_API_KEY" --bind 0.0.0.0:8788 server.harn
harn serve site --tls self-signed-dev server.harn
Mapping:
- the request dict mirrors the in-process
http_servershape:{ method, path, route, path_params, params, query, headers, body, body_base64, content_length, client_ip, remote_addr } bodyis the UTF-8-lossy view;body_base64is the standard-base64 encoding of the raw bytes, so binary payloads (e.g. a multipart upload) round-trip losslessly — recover them withbytes_from_base64(req.body_base64)and hand the result tomultipart_parse- header keys are lower-cased;
client_ipis read fromX-Forwarded-For/X-Real-IPwhen present - responses go through the same codec as every other surface, so
http_ok/http_created/http_not_modified/http_error/http_stream/http_sseplus content negotiation, ETag, and compression all behave identically - WebSocket handlers return
http_upgrade_ws(req, { on_message: "fn_name" }); the named function runs per inbound frame with a{type, data}/{type, data_base64}message dict, and its return value is sent back (a string verbatim, any other value as JSON,nilsends nothing) - unlike the dispatch adapters, the site host never replay-caches — an HTTP server must re-run its handler on every request
A2A
Choose A2A when the caller wants a peer agent rather than a bag of tools.
Typical fit:
- cross-agent delegation
- durable remote tasks
- resubscribe and callback-oriented delivery
Mapping:
- each exported
pub fnis advertised as an A2A skill in the agent card - inbound task text is passed to the selected exported function
- callers can select the exported function with
function,skillId, ormessage.metadata.target_agent; if there is only one export, that export is selected automatically - the shared dispatch core executes the same exported Harn function as the MCP adapter
- the A2A adapter owns agent cards, task lifecycle, push callbacks, cancellation, and resubscribe behavior
Run it with:
harn serve a2a server.harn
harn serve a2a --port 3000 server.harn
harn serve a2a --bind 0.0.0.0:3000 --api-key "$HARN_SERVE_API_KEY" server.harn
harn serve a2a --tls edge --public-url https://agent.example.com server.harn
harn serve a2a --tls self-signed-dev --port 3443 server.harn
harn serve a2a --cert certs/prod.pem --key certs/prod-key.pem server.harn
Behavior today:
harn serve a2abinds to127.0.0.1:8080by default; pass--bind 0.0.0.0:PORTonly when the server is protected by API-key/HMAC auth and TLS or a trusted edge proxy- HTTP JSON-RPC endpoint at
/ - A2A AgentCard at
/.well-known/agent-card.json, with compatibility aliases at/.well-known/a2a-agent,/.well-known/agent.json, and/agent/card - A2A 0.3.0 JSON-RPC methods
message/send,message/stream,tasks/get,tasks/cancel,tasks/resubscribe,tasks/pushNotificationConfig/{set,get,list,delete}, andagent/getAuthenticatedExtendedCard(returns an enriched card with declared security schemes and per-skilloutputSchemato authenticated callers; rejects unauthenticated calls with HTTP 401 plus aWWW-Authenticatechallenge, orExtendedAgentCardNotConfiguredError/-32007when noAuthPolicymethods are configured) - one-cycle compatibility aliases for
a2a.*,tasks/send,tasks/send_and_wait,tasks/sendSubscribe, andtasks/list, withDeprecation: trueon legacy HTTP responses - REST-style POST paths at
/message/send,/message/stream,/tasks/resubscribe, and/tasks/cancel, plus deprecated/tasks/sendand/tasks/send_and_waitaliases agent_progressevents stream as non-terminalworkingstatus updates with agent message text; entry lists render as markdown checklists and final task completion is still emitted separately- cooperative cancel propagation into the shared VM cancel token
- push notification callbacks from caller-provided task configuration
- HTTP auth hooks built on the shared
AuthPolicysurface: API keys HMAC canonical-request signatures When auth is configured, all task creation, task inspection, cancellation, streaming/resubscribe, and push notification config operations require it. - optional HS256 agent-card signatures with
--card-signing-secretorHARN_SERVE_A2A_CARD_SECRET
TLS modes
harn serve HTTP adapters accept the same TLS modes exposed by the HTTP
stdlib helpers:
--tls plain: bind a plain HTTP listener. Use only for loopback, trusted internal networks, or explicit cleartext development.--tls edge: bind a plain HTTP listener because an edge proxy, ingress, or load balancer terminates public TLS. The Harn layer treats the advertised scheme as HTTPS and emits HSTS headers. For A2A, pass--public-url https://...so agent cards point at the public edge URL.--tls self-signed-dev: generate an ephemeral self-signed certificate and serve HTTPS locally. This is for development only; HSTS is intentionally disabled so browsers are not pinned to a throwaway certificate.--tls pem --cert <chain.pem> --key <key.pem>or just--cert ... --key ...: load a PEM certificate chain and private key before the listener starts. A missing or invalid file is a startup failure, not a deferred request-time error.
Prefer edge termination for managed deployments where the platform already owns certificate issuance, renewal, WAF/rate-limit policy, or HTTP/2/HTTP/3 negotiation. Prefer PEM termination only when the Harn process is the TLS boundary. ALPN and SNI routing are intentionally left to edge infrastructure until Harn has a concrete in-process need.
ACP
Choose ACP when the caller wants a live agent session with host mediation.
Typical fit:
- editor hosts
- approval-aware local runtimes
- clients that already speak ACP session updates
Mapping:
harn serve acp <file.harn>starts the packaged stdio ACP adapterharn serve acp --transport websocket <file.harn>exposes the same packaged adapter over a local WebSocket endpoint- the adapter owns session state, prompt execution, permission prompts, cancel
tokens, and bidirectional
session/updatetraffic - the
_harn/providerCatalogextension method returns the same normalized provider/model catalog artifact asharn providers export - each
session/promptexposespromptas the text-only prompt string,prompt_contentas normalized Harn content blocks, andprompt_messagesas a user-role message list suitable forllm_call(..., {messages: prompt_messages})
Design rule
If a behavior changes depending on whether the caller arrived over MCP, A2A, or ACP, first ask whether it belongs in the adapter or in the shared core.
It belongs in the shared core when it affects:
- what function is invoked
- how arguments are normalized
- auth decisions
- replay semantics
- cancellation behavior
- observability or trust records
It belongs in the adapter when it affects:
- wire format
- discovery documents or cards
- streaming shape
- session lifecycle
- protocol-specific error envelopes