Harness protocol

Bring your own harness

An agent can run on its own harness instead of Lagrange, Claude Code or Codex. The harness is a command that runs in the agent's sandbox and talks to the platform over stdin and stdout, one JSON object per line. Any language works.

# lgr.yaml
name: support-bot
harness:
  command: python3
  args: [harness/main.py]
files: [harness]
setup: pip install --user -r harness/requirements.txt
secrets: [ANTHROPIC_API_KEY]

lgr deploy uploads harness/ with the rest of the agent's files. Each run then unpacks them, runs setup, and starts the command in the working directory. Runs show custom as their harness.

Environment

The process gets the run's environment: HOME, PATH, the agent's secrets, and the egress proxy variables when the platform runs one. It does not get the platform's model keys: to call a model, set your own key as a project secret (ANTHROPIC_API_KEY, OPENAI_API_KEY, ...) and list it in secrets. Runs on your own harness are billed for sandbox time only.

Messages from the platform (stdin)

The first line is the run:

{
  "type": "run",
  "harness": "custom",
  "approvals": false,
  "request": {
    "task": "Summarise the open tickets",
    "instructions": "Answer from the ticket history.",
    "workdir": "/workspace",
    "stateDir": "/lgr/state",
    "model": "claude-sonnet-4-6",
    "tools": ["Bash", "Read"],
    "mcpServers": [{ "name": "linear", "url": "https://mcp.linear.app/mcp", "token": "..." }],
    "skillsDir": "/home/lgr/.lgr/agent/skills",
    "limits": { "max_minutes": 30 },
    "resumeId": "the resumeId your harness returned last time"
  }
}

Only task and workdir are always there. stateDir persists on instances and profiles: keep conversation state there and return its id as resumeId to support continue. skillsDir holds the agent's skills as <name>/SKILL.md. Local MCP servers come with command, args and env (secrets filled in) instead of url and token.

Later lines are:

  • {"type": "approval", "id": "a1", "approved": true}: the answer to an approval you asked for.
  • {"type": "cancel"}: stop and send a cancelled result. The process is killed 10 seconds later.

Stdin closes once your result is received.

Messages to the platform (stdout)

Write one JSON object per line. Anything else on stdout is ignored; use stderr for logs (its last lines become the error if the process exits without a result).

Events appear on the run's timeline and trace:

{"type": "event", "event": {"type": "log", "data": {"level": "info", "message": "Loaded 12 tickets"}}}
{"type": "event", "event": {"type": "model.call.started", "span_id": "m1", "data": {"model": "claude-sonnet-4-6", "input": "..."}}}
{"type": "event", "event": {"type": "model.call.completed", "span_id": "m1", "data": {"output": "...", "tokens_in": 1200, "tokens_out": 80}}}
{"type": "event", "event": {"type": "tool.call.started", "span_id": "t1", "parent_span_id": "m1", "data": {"tool": "search", "input": {"q": "refund"}}}}
{"type": "event", "event": {"type": "tool.call.completed", "span_id": "t1", "data": {"tool": "search", "output": "3 results"}}}
Eventdata
loglevel (info, warn, error), message
harness.plantext
harness.verifysummary, result (pass or fail)
model.call.started / model.call.completedmodel, input / output, tokens_in, tokens_out
model.deltatext
tool.call.started / tool.call.completedtool, input / output or error
subagent.started / subagent.completedname, input / output
context.compactedanything useful

span_id pairs a started event with its completed one; parent_span_id nests spans in the trace. Other event types (run lifecycle, approvals, artifacts) belong to the platform and are dropped.

To ask a person before a tool call (only when the run message has "approvals": true):

{"type": "approval", "id": "a1", "request": {"tool": "send_email", "input": {"to": "..."}}}

Finish with exactly one result:

{"type": "result", "result": {"status": "succeeded", "result": "3 tickets need a reply", "model": "claude-sonnet-4-6", "usage": {"tokens_in": 5200, "tokens_out": 640}, "resumeId": "conv_42"}}

status is succeeded, failed (with error) or cancelled. Files the harness writes in the working directory become the run's artifacts.

A minimal harness

import json, sys

def send(msg):
    print(json.dumps(msg), flush=True)

run = json.loads(sys.stdin.readline())["request"]
send({"type": "event", "event": {"type": "log", "data": {"message": f"working on: {run['task']}"}}})
# ... call your model, use tools, write files ...
send({"type": "result", "result": {"status": "succeeded", "result": "done", "usage": {}}})