Skip to main content

Harness API: New Sessions and Messages

When a background agent runs on the Custom platform, Willow does not run the agent — your harness does. Willow's job is to decide when a conversation starts or continues and to POST that conversation turn to an HTTP endpoint you own.

This page is the wire-level reference for those outbound calls. It documents exactly what Willow sends, when it sends it, and what it expects back. For the other direction — how your harness authenticates and calls the agent's tools back through the gateway — see Custom Agents & Self-Hosted Harnesses.

This page covers two platforms

Custom agents deliver to per-agent URLs set on the agent itself (Settings → Platform Configuration → Harness endpoints). Custom Managed agents deliver to org-wide URLs set on a custom agent type. The payloads differ slightly; both are documented below.

When Willow calls your harness

Every path that starts or continues an agent conversation converges on one internal flow, which then makes a single outbound POST to your harness. The sources are:

SourceWhat triggers it
API triggerPOST /agent-api/<slug>/trigger/<triggerId> from your own code.
Scheduled triggerThe agent's cron schedule comes due.
Integration webhookA connector webhook workflow with a background-agent action matches.
End-user chatA user sends a message to the agent from the Willow end-user app.
Admin invoke APIPOST /api/background-agents/<id>/invoke with an admin token.
Agent-to-agentAnother agent calls its send_message tool.

All of them resolve two values — a session_key and a message — and hand them to the delivery step described below.

Which endpoint receives the call

Custom agents have two settings under Harness endpoints:

SettingMeaning
Single endpoint (default: on)One URL handles both new sessions and follow-up messages.
New session endpoint (session_url)Only used when Single endpoint is off, and only for the first turn of a conversation.
Agent / New message endpoint (message_url)Everything else.

The resolved URL is:

Single endpointFirst turn of a conversationEvery following turn
Onmessage_urlmessage_url
Offsession_url (falls back to message_url if unset)message_url
The event name is independent of the URL

Turning Single endpoint on does not mean every request looks the same. The first turn of a conversation is always sent with "event": "agent.session", even when it arrives on message_url. Branch on the event field, not on the URL.

If no URL is configured at all, delivery fails before any request is made and the caller receives a 502 with the message "The agent has no harness delivery endpoint configured."

Request format

PropertyValue
MethodPOST
HeadersContent-Type: application/json only. Custom agents send no Authorization header.
BodyJSON, described below.
Timeout20 seconds.
RetriesNone. Each conversation turn is a single attempt.
SignatureNone. There is no HMAC or request signing.
Secure the endpoint yourself

Because Custom-platform harness calls carry no credentials, treat the endpoint URL as the secret: use an unguessable path or a token in the query string, restrict it to Willow's egress addresses, and reject anything that does not match. If you want a real auth header instead, register the agent as a Custom Managed type, which supports a configurable token.

agent.session — first turn of a conversation

Sent the first time Willow sees a given session_key for this agent.

{
"event": "agent.session",
"agent_id": "9f2c1b7e-0f8a-4a1e-b9d8-2f4c6a1e7b30",
"agent_slug": "release-notes-writer",
"session_key": "custom:trg_8ac1:2026-08-16T09:15:00.000Z",
"message": "Summarize the PRs merged today."
}

Your harness should create whatever it needs for a new conversation — a thread, a workspace, a context window — store it under session_key, and start working on message.

agent.message — a follow-up turn

Sent for every later turn with the same session_key.

{
"event": "agent.message",
"agent_id": "9f2c1b7e-0f8a-4a1e-b9d8-2f4c6a1e7b30",
"agent_slug": "release-notes-writer",
"session_key": "custom:trg_8ac1:2026-08-16T09:15:00.000Z",
"message": "Also include the reverted commits."
}

Look the conversation up by session_key and append the turn to it.

Field reference

FieldTypeDescription
event"agent.session" | "agent.message"Whether this is the first turn of the conversation or a follow-up.
agent_idstring (UUID)Willow's id for the agent. Stable across renames and slug changes.
agent_slugstringThe agent's slug, matching its MCP and REST endpoints.
session_keystring, max 512 charsConversation correlation key. See How session_key is chosen.
messagestringThe prompt to run. Never empty.

Response contract

Return any 2xx status to acknowledge the turn. The body is not read and may be empty.

Anything else is a delivery failure. Willow raises the harness status and the first 300 characters of your response body, and the original caller receives a 502 Bad Gateway:

{
"statusCode": 502,
"error": "Bad Gateway",
"message": "Failed to deliver message to the agent harness: Harness endpoint responded with 401: missing token"
}
Acknowledge fast, run the agent asynchronously

The 20-second timeout is a delivery timeout, not a run budget. A harness that runs the agent inline and answers only when it finishes will time out on any non-trivial task, and the turn is not retried. Enqueue the work, return 202 Accepted immediately, and do the run in the background.

Willow records the session as delivered only after a 2xx, so a failed first turn is retried as agent.session (not agent.message) the next time the same session_key fires.

How session_key is chosen

session_key is what makes several deliveries one conversation. Willow keeps a row per (agent, session_key): the first delivery for a key sends agent.session, every later one sends agent.message. The key is trimmed and truncated to 512 characters.

Each source builds it differently:

Sourcesession_key
API trigger, session_key in the bodyThe value you send, verbatim.
API trigger, session strategy sharedcustom:<triggerId> — every call continues one long-lived conversation.
API trigger, session strategy newcustom:<triggerId>:<ISO timestamp> — a fresh conversation per call.
Scheduled trigger, sharedscheduled:<triggerId>
Scheduled trigger, newscheduled:<triggerId>:<ISO timestamp>
Integration webhookThe workflow's session strategy: the connector's suggested key (for example a PR number or Slack thread id), one shared key for the workflow, or a JSONata expression you write over { headers, body }.
End-user chatA per-user opaque key, reused for the whole conversation.
Admin invoke APIThe session_key you pass in the request body (required).

Pick a key that means "one conversation" in your domain — a ticket id, a pull request number, a Slack thread — and Willow's correlation will match your harness's.

Custom Managed agents

A custom agent type registers org-wide endpoints that any number of agents share. The delivery contract differs from the Custom platform in four ways:

  • Messages always go to the type's message endpoint. There is no separate session URL.
  • The event is always agent.message, including the first turn. Use session_key to decide whether the conversation is new.
  • The payload carries an extra external_agent_id.
  • If the type stores an auth token, it is sent on the configured header — Authorization: Bearer <token> by default, or the raw token when a custom header name is used.
{
"event": "agent.message",
"agent_id": "9f2c1b7e-0f8a-4a1e-b9d8-2f4c6a1e7b30",
"agent_slug": "release-notes-writer",
"external_agent_id": "your-side-id",
"session_key": "T-123",
"message": "Summarize the PRs merged today."
}

external_agent_id is the id your sync endpoint returned from agent.sync; it is absent if you never returned one. Custom Managed agents must be synced before they can receive messages — an unsynced agent fails with 400 Agent is not synced to its platform.

Worked example

A minimal Express harness that satisfies the contract: authenticate on a shared secret, branch on event, acknowledge immediately, and run the agent out of band.

import express from "express"

const app = express()
app.use(express.json())

const sessions = new Map()

app.post("/willow/:secret", (req, res) => {
if (req.params.secret !== process.env.WILLOW_HARNESS_SECRET) {
return res.sendStatus(404)
}

const { event, agent_slug, session_key, message } = req.body

if (event === "agent.session") {
sessions.set(session_key, { agent: agent_slug, turns: [] })
} else if (!sessions.has(session_key)) {
// A first turn whose delivery failed can arrive as agent.message later.
sessions.set(session_key, { agent: agent_slug, turns: [] })
}

sessions.get(session_key).turns.push(message)

res.status(202).json({ accepted: true })
runAgent(session_key).catch((err) => console.error(err))
})

app.listen(3000)

Inside runAgent, call the agent's tools back through the Willow gateway with the agent's client credentials, as described in Calling tools. Those calls are what keep the agent inside its permission boundary and inside the audit log.

Testing and troubleshooting

Use Test on the agent's Overview tab to send a turn without wiring up a trigger. Edit the JSON payload — it defaults to a session_key and a message — and select Run test.

SymptomCause
502 … has no harness delivery endpoint configuredNeither message_url nor session_url is set on the agent.
502 … Harness endpoint responded with <status>Your endpoint returned a non-2xx. The first 300 characters of your body are included.
502 … The operation was abortedYour endpoint took longer than 20 seconds. Acknowledge first, run asynchronously.
400 Agent is disabledThe agent's status toggle is off.
400 Agent is not synced to its platformA Custom Managed agent that has never synced successfully.
Every turn arrives as agent.sessionYour endpoint is not returning 2xx, so the session is never recorded as delivered.

What to do next