Skip to main content
This page applies when you point archal run at a repo-local harness and also pass --docker. Repo-local harnesses otherwise run locally by default. In Docker harness mode, Archal builds and runs your repo in Docker, injects environment variables that describe the scenario and the live twins, and captures your container’s stdout as the agent response. This page describes that contract so you can build your own harness from scratch.

How it works

  1. Archal resolves the repo-local harness you passed to --harness.
  2. Archal builds your image from the repo-root Dockerfile (or a generated one if none exists).
  3. Archal starts a local TLS-intercepting proxy that transparently forwards twin API traffic to the hosted cloud twins and adds the session auth header.
  4. Archal runs the container, passing all env vars described below and mounting a config directory at /archal-out/.
  5. Your container exits. Archal reads stdout as the agent response, stderr is logged, and non-zero exit code marks the run as failed.

Environment variables

The following env vars are set in every container. All are strings.

Required — always present

VariableContains
ARCHAL_ENGINE_TASKThe scenario task text the agent must complete. This is the full natural-language instruction derived from the scenario’s setup and expected behavior.
ARCHAL_ENGINE_MODEAlways "local". Identifies this as a local harness run.
ARCHAL_TOKENBearer token for twin REST calls. Pass as Authorization: Bearer $ARCHAL_TOKEN on every request.
ARCHAL_TWIN_URLSJSON object mapping twin names to their base URLs, e.g. {"github":"https://…","slack":"https://…"}.
ARCHAL_TWIN_NAMESComma-separated list of twin names, e.g. github,slack. Use this to enumerate which ARCHAL_<TWIN>_URL vars are present.
ARCHAL_REST_CONFIGAbsolute path to /archal-out/rest-config.json inside the container.
ARCHAL_MCP_CONFIGAbsolute path to /archal-out/mcp-config.json inside the container.
ARCHAL_MCP_SERVERSInline JSON string — same content as mcp-servers.json (see Mounted files). Useful if you do not want to read a file.
ARCHAL_METRICS_FILEAbsolute path to /archal-out/metrics.json. Write a JSON metrics payload here before exiting (see Metrics below).
ARCHAL_AGENT_TRACE_FILEAbsolute path to /archal-out/agent-trace.json. Write a trace payload here before exiting (optional).
ARCHAL_API_PROXY_URLURL of the local TLS proxy, e.g. http://host.docker.internal:PORT. All HTTPS traffic to twin domains routes through this proxy automatically.

Per-twin URL vars

For each twin named in ARCHAL_TWIN_NAMES, a dedicated URL var is also injected:
ARCHAL_GITHUB_URL=https://…
ARCHAL_SLACK_URL=https://…
The pattern is ARCHAL_<TWIN_NAME_UPPERCASE>_URL.

Optional — present when set by the caller

VariableContains
ARCHAL_ENGINE_MODELModel identifier the harness should use, e.g. claude-sonnet-4-6. Set by --agent-model or -m.
ARCHAL_SESSION_IDArchal session ID for the current run.
ARCHAL_ENGINE_API_KEYGeneric API key forwarded from the host.
ANTHROPIC_API_KEYForwarded from the host environment if set.
OPENAI_API_KEYForwarded from the host environment if set.
GEMINI_API_KEYForwarded from the host environment if set.
NODE_ENVForwarded from the host environment if set.

TLS proxy vars

These are injected automatically so standard HTTP clients trust the intercepting proxy without any code changes:
VariableValue
HTTP_PROXY / http_proxyProxy URL
HTTPS_PROXY / https_proxyProxy URL
NO_PROXY / no_proxy127.0.0.1,localhost,host.docker.internal
NODE_EXTRA_CA_CERTS/archal-out/ca.crt
SSL_CERT_FILE/archal-out/ca.crt
REQUESTS_CA_BUNDLE/archal-out/ca.crt (for Python requests)
CURL_CA_BUNDLE/archal-out/ca.crt
Node.js, Python requests, and curl all respect these vars out of the box. Other runtimes may need explicit CA configuration pointing to /archal-out/ca.crt.

Mounted files

The directory /archal-out/ is bind-mounted into the container. It contains:
FileContents
rest-config.json{ "restEndpoints": { "<twin>": "<baseUrl>", … } } — a map of twin names to their REST base URLs.
mcp-config.json{ "mcpServers": { "<twin>": { "url": "<mcpUrl>", "headers": { "Authorization": "Bearer …" } }, … } } — ready-to-use MCP server config.
mcp-servers.jsonSame content as mcp-config.json’s mcpServers key, without the wrapper object.
ca.crtPEM-encoded CA certificate for the intercepting proxy. Trust this cert in any runtime that does not read the standard env vars above.

Tool discovery

Call GET {twinBaseUrl}/tools to retrieve all tools a twin exposes. No request body is required. The Authorization header is required.
curl -s \
  -H "Authorization: Bearer $ARCHAL_TOKEN" \
  "$ARCHAL_GITHUB_URL/tools"
Response — a JSON array:
[
  {
    "name": "create_issue",
    "description": "Create a new issue in a repository.",
    "inputSchema": {
      "type": "object",
      "properties": {
        "owner": { "type": "string" },
        "repo":  { "type": "string" },
        "title": { "type": "string" }
      },
      "required": ["owner", "repo", "title"]
    }
  }
]
Each tool has:
  • name — tool identifier used in the call endpoint.
  • description — human-readable description.
  • inputSchema — JSON Schema object describing accepted parameters.
To build a namespaced tool list across all twins, iterate ARCHAL_TWIN_NAMES and prefix each tool name with mcp__<twinName>__: Always pass a signal: AbortSignal.timeout(...) to every twin fetch. Without it, a hung twin worker leaves your harness blocking forever (#2169). 15s is a sensible default for /tools and /tools/call.
const twinNames = process.env.ARCHAL_TWIN_NAMES.split(',');
const allTools = [];
for (const name of twinNames) {
  const baseUrl = process.env[`ARCHAL_${name.toUpperCase()}_URL`];
  const res = await fetch(`${baseUrl}/tools`, {
    headers: { Authorization: `Bearer ${process.env.ARCHAL_TOKEN}` },
    signal: AbortSignal.timeout(15000),
  });
  const tools = await res.json();
  for (const tool of tools) {
    allTools.push({ ...tool, name: `mcp__${name}__${tool.name}` });
  }
}

Tool execution

Call POST {twinBaseUrl}/tools/call to execute a tool. The Authorization header is required.
curl -s -X POST \
  -H "Authorization: Bearer $ARCHAL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "create_issue", "arguments": {"owner": "acme", "repo": "backend", "title": "Fix login bug"}}' \
  "$ARCHAL_GITHUB_URL/tools/call"
Request body:
{
  "name": "create_issue",
  "arguments": { "owner": "acme", "repo": "backend", "title": "Fix login bug" }
}
  • name — the original (non-namespaced) tool name as returned by /tools.
  • arguments — object matching the tool’s inputSchema. Pass {} for tools with no required parameters.
The response body is a JSON string on success. On error, the response is non-2xx and the body is either a plain error message or a JSON object with a message field.

Output contract

Stream / codeMeaning
stdoutCaptured as the agent response text. Write your final answer or summary here.
stderrLogged by Archal for debugging. Write progress, tool call results, and diagnostics here.
exit 0Run succeeded. Archal proceeds to evaluation.
exit non-zeroRun failed. Archal marks the run as an error and skips evaluation for this run.
Your harness should write its final answer to stdout exactly once, at the end of execution. Progress output, tool call logs, and error messages should go to stderr.

Metrics file (optional)

Write a JSON payload to $ARCHAL_METRICS_FILE before exiting to surface token usage in the run report:
{
  "version": 1,
  "inputTokens": 12400,
  "outputTokens": 830,
  "llmCallCount": 7,
  "toolCallCount": 14,
  "toolErrorCount": 0,
  "totalTimeMs": 18240,
  "exitReason": "completed",
  "provider": "anthropic",
  "model": "claude-sonnet-4-6"
}
All fields are optional. Archal reads the file after the container exits. If the file is absent or malformed, metrics are silently skipped — it does not cause a run failure. exitReason should be one of: completed, max_steps, no_tool_calls, consecutive_errors, llm_error.

Minimal harness example

#!/usr/bin/env node
// minimal-harness.mjs

const task  = process.env.ARCHAL_ENGINE_TASK;
const model = process.env.ARCHAL_ENGINE_MODEL;
const token = process.env.ARCHAL_TOKEN;

// 1. Discover tools from all twins
const twinNames = (process.env.ARCHAL_TWIN_NAMES || '').split(',').filter(Boolean);
const allTools = [];
const toolToTwin = {};

for (const twinName of twinNames) {
  const baseUrl = process.env[`ARCHAL_${twinName.toUpperCase()}_URL`];
  const res = await fetch(`${baseUrl}/tools`, {
    headers: { Authorization: `Bearer ${token}` },
    signal: AbortSignal.timeout(15000),
  });
  for (const tool of await res.json()) {
    const nsName = `mcp__${twinName}__${tool.name}`;
    allTools.push({ ...tool, name: nsName });
    toolToTwin[nsName] = { twinName, baseUrl, originalName: tool.name };
  }
}

// 2. Run your agent against the LLM — call tools via /tools/call
async function callTool(namespacedName, args) {
  const { baseUrl, originalName } = toolToTwin[namespacedName];
  const res = await fetch(`${baseUrl}/tools/call`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
    body: JSON.stringify({ name: originalName, arguments: args ?? {} }),
    // Always pass a timeout — see #2169. A hung twin should not freeze the agent.
    signal: AbortSignal.timeout(15000),
  });
  return res.text();
}

// ... your agent loop here ...

// 3. Write final answer to stdout
process.stdout.write('Agent completed the task.\n');

Using the bundled REST client

The bundled harnesses share a helper library at cli/harnesses/_lib/rest-client.mjs in the Archal repo. You can copy or vendor this file — it exports collectTwinUrls, discoverAllTools, and callToolRest, which implement the full discovery and execution protocol described above including namespacing.