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 acancelledresult. 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"}}}| Event | data |
|---|---|
log | level (info, warn, error), message |
harness.plan | text |
harness.verify | summary, result (pass or fail) |
model.call.started / model.call.completed | model, input / output, tokens_in, tokens_out |
model.delta | text |
tool.call.started / tool.call.completed | tool, input / output or error |
subagent.started / subagent.completed | name, input / output |
context.compacted | anything 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": {}}})