Skip to main content

Custom Agents & Self-Hosted Harnesses

The Custom platform lets a background agent run on your own runtime instead of a Willow-managed one. Your harness authenticates with the agent's client credentials and calls the agent's assigned tools back through the Willow gateway — over MCP or a plain REST API. You can also let Willow push work to your harness: triggers and messages are POSTed to endpoints you configure.

Open the agent from Build → Background Agents, choose a Custom agent, and use the Connect button on the Overview tab (or the Connection panel in Settings → Platform Configuration) to find its endpoints and credentials.

This page documents every payload involved, in both directions:

DirectionWhat it covers
Your harness → WillowAuthenticate, call tools over MCP, call tools over REST, fire API triggers.
Willow → your harnessSync and message payloads delivered to your configured endpoints.

Authentication

Every request to the gateway is authenticated with the agent's client credentials:

CredentialWhere it comes from
Client IDThe agent's access_key.
Client SecretThe agent's secret, shown once when you generate or rotate it.

Generate the pair from the agent's Connection panel with Get authorization token (the secret is minted on first use). Send them together as a bearer token:

Authorization: Bearer <client_id>:<client_secret>
note

The client secret is only shown at creation and when rotated. Store it in a secrets manager — it cannot be retrieved later. Regenerating a secret immediately invalidates the previous one.

Connecting to your harness

The Connect dialog exposes two base URLs:

EndpointURL shapeUse it for
MCP endpointhttps://<your-gateway>/agent/<slug>Speak the Model Context Protocol from any MCP-compatible host.
API endpointhttps://<your-gateway>/agent-api/<slug>Call a single tool with a plain HTTP POST.

The agent only ever exposes the tools, skills, and rules assigned to it — its capabilities are its permission boundary.

MCP configuration

Copy this from Connect → MCP config (or download the ZIP bundle). Drop it into any MCP host:

{
"mcpServers": {
"<slug>": {
"type": "http",
"url": "https://<your-gateway>/agent/<slug>",
"headers": {
"Authorization": "Bearer <client_id>:<client_secret>"
}
}
}
}

Calling tools

Option A: MCP

Connect to the MCP endpoint with the Authorization header above and call tools using standard MCP tools/list and tools/call requests. Every call flows through the gateway and is subject to the same guards, policies, and logging as any other traffic.

Option B: REST API

Prefer plain HTTP? Each enabled tool is also a POST endpoint.

Discover the tools the agent exposes:

curl https://<your-gateway>/agent-api/<slug> \
-H "Authorization: Bearer <client_id>:<client_secret>"
{
"data": {
"agent": "<slug>",
"description": "Call any of this agent's tools with a direct POST request. The JSON request body is passed through as the tool arguments.",
"tools": [
{
"name": "slack__send_message",
"description": "Send a message to a Slack channel",
"input_schema": { "type": "object", "properties": { "channel": { "type": "string" }, "text": { "type": "string" } } }
}
]
}
}

Call a tool. The path is <integration_slug>__<tool_slug> and the JSON body is passed straight through as the tool arguments:

curl -X POST https://<your-gateway>/agent-api/<slug>/slack__send_message \
-H "Authorization: Bearer <client_id>:<client_secret>" \
-H "Content-Type: application/json" \
-d '{ "channel": "#alerts", "text": "Deploy finished" }'

The response mirrors an MCP tool result:

{
"isError": false,
"content": [
{ "type": "text", "text": "Message sent to #alerts" }
]
}

A 4xx status with "isError": true means the tool wasn't found, isn't enabled for this agent, or the call failed — the reason is in content[].text.

Firing an API trigger

An API trigger starts an agent conversation on demand. Create one on the agent's Triggers tab; it has a stored prompt (message) and an optional Session ID (JSONata) expression. Call it at:

POST https://<your-gateway>/agent-api/<slug>/trigger/<triggerId>
Authorization: Bearer <client_id>:<client_secret>
Content-Type: application/json

The request body is optional:

{
"message": "Optional prompt that overrides the trigger's stored message",
"session_key": "Optional id that correlates this call into a conversation"
}
FieldTypeBehavior
messagestringOverrides the trigger's saved prompt for this call. Omit it to use the saved prompt.
session_keystringCorrelates the call to a conversation. If it matches an existing session that conversation is resumed; otherwise a new one starts.

How the session is chosen

The session id is resolved in this order:

  1. session_key in the body, if present.
  2. The trigger's Session ID (JSONata) expression, evaluated against the request body. For example, with the expression payload.thread_id and a body of { "payload": { "thread_id": "T-123" } }, the session id is T-123. Use this to correlate runs by an id that already lives in your webhook payload.
  3. A fresh conversation on each call, when neither is provided.

The response reports where the message landed:

{
"isError": false,
"content": [{ "type": "text", "text": "Trigger fired" }],
"session_id": "T-123",
"session_key": "T-123",
"created": true
}

created is true when a new conversation was started and false when an existing one was resumed.

Receiving work on your harness

Instead of only pulling tools, you can have Willow push conversations to your own harness. Configure the delivery endpoints in the agent's Connection panel:

SettingMeaning
Single endpoint (default on)One URL handles both new sessions and every follow-up message.
New session endpointCalled to start a new session (only when Single endpoint is off).
New message endpointCalled to deliver messages to your harness.

Willow POSTs JSON to these URLs with Content-Type: application/json. If your platform stores an auth token, it is sent on the configured header (defaults to Authorization: Bearer <token>).

1. agent.sync (agent snapshot)

Sent when the agent is synced, so your harness has the current definition. The agent_token is the agent's client_id:client_secret bearer token, so your harness can call the gateway:

{
"event": "agent.sync",
"agent": {
"id": "agent_abc123",
"name": "Release Notes Writer",
"slug": "release-notes-writer",
"description": "Drafts release notes from merged PRs",
"status": "active",
"capabilities": {
"tools": [],
"skills": [],
"rules": []
},
"platform_config": {},
"mcp_url": "https://<your-gateway>/agent/release-notes-writer"
},
"agent_token": "<client_id>:<client_secret>"
}

Respond 2xx to acknowledge. You may echo metadata that Willow stores on the sync state:

{ "external_agent_id": "your-side-id", "external_agent_version": 3 }

2. agent.message (a conversation turn)

Sent when a trigger, schedule, or webhook delivers a message. Correlation is delegated to your harness via session_key:

{
"event": "agent.message",
"agent_id": "agent_abc123",
"agent_slug": "release-notes-writer",
"external_agent_id": "your-side-id",
"session_key": "T-123",
"message": "Summarize the PRs merged today."
}
FieldMeaning
session_keyConversation id. Resume the matching session, or start one on first use.
external_agent_idThe id you returned from agent.sync, when set.
messageThe prompt to deliver to the agent.

Return any 2xx status to acknowledge receipt. A non-2xx response is surfaced in Willow as a delivery error.

Testing an agent

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

{
"session_key": "test-a1b2c3",
"message": "Hi, this is a test run. Give a short summary of what you can do."
}

Willow starts (or resumes) a conversation on the agent's platform and returns the resulting session_id and whether it was newly created.

What to do next