Build an external agent
Create, connect, and host your own external (custom) AI agent on monday.com: webhook setup, signature verification, responding in agent chat, and acting on boards under the agent's identity
This guide walks through building a custom external agent ("bring your own agent") for monday.com end to end: creating it programmatically, pointing its webhook at your service, verifying requests, and — the part that trips most people up — responding correctly so replies render in the agent chat.
Pre-release feature. The agents API is currently available only in the
dev(pre-release) API version. It is not included in any dated API version yet. Signatures, types, and behavior can change before this lands in a dated version. Every request must send theAPI-Version: devheader.
Early preview: This API is in early preview. Fill out this form to request early access.
For the full API specification (every query, mutation, type, and enum), see the Agents reference. For the conceptual overview of how agents fit into the platform, see Build on monday.com with AI.
Concepts
monday.com supports two kinds of agent that you don't have to build a UI for:
- Agents — AI work orchestrators created and configured inside monday.com (personal or account-level). You define a profile, goal, plan, skills, knowledge, and triggers through the API, and monday.com runs them. See the Agents reference.
- External agents — agents that you host and own. monday.com gives them a first-class identity (they can be @mentioned, assigned to items, and act on boards) and communicates with your service over a signed webhook. This guide covers external agents.
There are two ways to connect an external agent:
- Managed provider (e.g. a Claude-managed agent) — monday.com orchestrates calls to the provider on your behalf. Connect it with the asynchronous
connect_external_agentmutation. This guide does not cover managed providers. - Custom agent (webhook) — you provide a callback URL that monday.com posts events to, and your service replies. Connect it with the synchronous
connect_external_agent_syncmutation. This is what this guide covers.
At a high level, the custom agent flow looks like this:
monday.com ──(signed POST: "agent_triggered")──▶ your callback URL
your service ──(SSE stream / JSON response)──────▶ monday.com (the reply)
your service ──(GraphQL with the agent's token)──▶ monday.com (actions on items)You receive two credentials once at setup:
| Credential | Purpose |
|---|---|
signing_secret | Verify that incoming webhooks really came from monday.com (HMAC). |
api_token | Act as the agent when calling the monday.com GraphQL API. |
Prerequisites
- A monday.com account with Agents enabled (early-access feature).
- An owner token to create and manage the agent — a monday.com user token (OAuth access token or a personal API token). Management mutations run in the context of this user and under their permissions.
- A public HTTPS endpoint that monday.com can reach (see Deployment notes).
- All agent API calls must send the header
API-Version: dev.
A small GraphQL helper used throughout this guide:
async function mondayApi(token, query, variables = {}) {
const res = await fetch('https://api.monday.com/v2', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: token, // owner token OR agent api_token
'API-Version': 'dev', // REQUIRED for the agents API
},
body: JSON.stringify({ query, variables }),
});
return res.json();
}Create the agent
Use connect_external_agent_sync with the owner token.
This mutation is synchronous and slow (~25 seconds). Set your HTTP client timeout to at least 40 seconds and show a loading state while you wait.
mutation ConnectCustomAgent($input: ConnectExternalAgentSyncInput!) {
connect_external_agent_sync(input: $input) {
agent_id
signing_secret
api_token
instructions
}
}Variables:
{
"input": {
"custom": {
"name": "My Custom Agent",
"callback_url": "https://your-public-host.example.com/agent/webhook"
}
}
}const data = await mondayApi(ownerToken, CONNECT_MUTATION, {
input: { custom: { name, callback_url } },
});
const { agent_id, signing_secret, api_token, instructions } =
data.data.connect_external_agent_sync;Response fields
| Field | Notes |
|---|---|
agent_id | Internal monday.com agent ID. |
signing_secret | Shown once. Store securely — used to verify webhooks. |
api_token | Shown once. Store securely — used to act as the agent. |
instructions | Optional setup notes. |
signing_secretandapi_tokenare returned only once and can never be retrieved again. Persist them immediately (e.g. a secrets manager or monday-code secure storage). If lost, you mustdisconnect_external_agentand reconnect.
The callback_url must be public HTTPS and reachable from monday.com's infrastructure. It is re-validated at execution time as protection against SSRF and DNS-rebinding. Do not use localhost or an internal host (see Deployment notes).
Prefer the API but want to start in the UI? You can also create a custom agent from Agents → Manage agents → Bring your agent → Custom agent, then copy the credentials from the success modal (shown only once).
Set or update the callback URL
To change the name or callback URL later, use update_custom_agent.
update_custom_agentrequires the owner's token, not the agent'sapi_token. Using the agent token returns an authorization error.
mutation UpdateCustomAgent($input: UpdateCustomAgentInput!) {
update_custom_agent(input: $input) {
success
}
}{
"input": {
"agent_id": 1234567890,
"name": "My Renamed Agent",
"callback_url": "https://your-public-host.example.com/agent/webhook"
}
}name and callback_url are both optional and independent. Errors (not found, not owner, invalid URL) come back as GraphQL errors, not success: false. The new URL must be a valid public HTTPS endpoint.
Activate the agent
Newly created agents start inactive and must be activated before they can run. Use activate_agent.
mutation {
activate_agent(id: 1234567890) {
success
}
}Grant board access
A connected agent acts under its own identity — your personal board access does not carry over. Grant it access to the boards and docs it needs with add_agent_resource_access.
mutation {
add_agent_resource_access(
id: 1234567890,
resource_id: 9876543210,
scope_type: BOARD, # or DOC
permission_type: READ_WRITE # READ or READ_WRITE
) {
success
}
}READ_WRITE is required to create or edit items, or to post updates.
You can also grant access in the UI from the agent's page → Knowledge and access. If a board is in a non-default data region, the agent's API token must be able to reach that region.
Receive triggers (the webhook)
When the agent is triggered, monday.com sends a signed POST to your callback URL and waits for your reply on the same request. There is no separate reply token or reply URL — the request is synchronous.
Headers
| Header | Example | Use |
|---|---|---|
x-monday-agent-id | 139988 | Which agent fired → look up its stored credentials. |
x-monday-signature | sha256=a3a6ce… | HMAC of the body — verify it. |
x-monday-timestamp | 1782326623754 | Epoch milliseconds; part of the signed string. |
content-type | application/json | The request body is JSON. |
Agents hosted on monday-code (Cloud Run) also receive infrastructure headers:
x-serverless-authorization(a Google-signed JWT),host(the internal…---service-….a.run.apphost — not your public URL; never derive your callback URL from it),x-forwarded-*,cf-*,via: 1.1 google, anduser-agent: node. Sign and route on thex-monday-*headers above, not on these.
Body envelope
The body always has the same shape; only payload varies by trigger type.
{
"event": "agent_triggered",
"triggerType": "chat",
"payload": { "text": "...", "...": "trigger-specific fields" },
"timestamp": "2026-06-24T18:43:43.754Z"
}| Field | Type | Description |
|---|---|---|
event | String | Always agent_triggered for custom agent webhooks. |
triggerType | String | How the agent was triggered: chat, assigned, mention, or unknown. |
payload | Object | Trigger-specific data. Always includes text; other fields depend on triggerType. |
timestamp | String | ISO 8601 time the event occurred. |
Trigger types
chat
chatThe user typed a message in the agent chat. The payload is minimal and contains no IDs — only text. This is why a chat reply must be returned in the HTTP response: there is nothing to call back to.
{
"event": "agent_triggered",
"triggerType": "chat",
"payload": { "text": "sent this message through agent chat" },
"timestamp": "2026-06-24T18:43:43.754Z"
}The chat request may include a stream flag (defaults to true). See Respond to triggers.
assigned
assignedThe agent was assigned to an item. The payload carries the full target, and monday.com formats text as an instruction prompt:
{
"event": "agent_triggered",
"triggerType": "assigned",
"payload": {
"text": "You were assigned to an item. Follow these steps:\n1. ...\n\nTRIGGER DATA:\n- itemId: 12334011531\n- boardId: 18418747579\n- groupId: topics",
"itemId": 12334011531,
"boardId": 18418747579,
"groupId": "topics",
"updateId": null,
"replyId": null,
"updateBody": null,
"files": null
},
"timestamp": "2026-06-24T18:45:26.706Z"
}With itemId/boardId present, respond by acting on the item via the API.
mention
mentionThe agent was @mentioned in an update. Like assigned, the payload carries target IDs you can act on via the API. A mention only delivers a webhook when the agent has access to the relevant board and is mentioned in a valid context.
Limits: 30-second request timeout; response body ≤ 1 MB.
Verify the request signature
Verify every incoming request before processing it. Compute an HMAC-SHA256 over the string `${timestamp}.${rawBody}` using the signing_secret, prefix the hex digest with sha256=, and compare it to x-monday-signature using a constant-time comparison.
import crypto from 'crypto';
function verifySignature(signingSecret, rawBody, headers) {
const timestamp = headers['x-monday-timestamp'];
const received = headers['x-monday-signature'];
if (!timestamp || !received) return false;
const expected = 'sha256=' + crypto
.createHmac('sha256', signingSecret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(received);
// timingSafeEqual throws on length mismatch — guard first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}HMAC the raw, unparsed request body bytes. If you re-serialize a parsed
req.body, key ordering and whitespace will differ and the check will fail. Capture the raw body, e.g. with anexpress.json({ verify })hook. Use thesigning_secrethere — not theapi_token.
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; },
}));Respond to triggers
This is the part that makes chat work. monday.com reads the agent's reply synchronously from the HTTP response. The default is a Server-Sent Events (SSE) stream; a request can opt out with stream: false.
A plain JSON body like
{ "text": "..." }for a chat trigger does not render — the run shows "Something went wrong while running the agent." You must stream SSE (or use the non-stream JSON shape below).
Streaming reply (default — used by chat)
- Respond with
200andContent-Type: text/event-stream. - Emit one or more events:
data: {"type":"text","content":"<piece>"}followed by a blank line. - Terminate the stream with
data: [DONE]followed by a blank line. - monday.com concatenates the
contentpieces into the chat message.
function streamReply(res, replyText) {
res.status(200);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
if (res.flushHeaders) res.flushHeaders();
const tokens = replyText.split(' ');
let i = 0, closed = false;
res.req.on('close', () => { closed = true; });
const send = () => {
if (closed) return;
if (i < tokens.length) {
const isLast = i === tokens.length - 1;
const content = tokens[i] + (isLast ? '' : ' ');
i += 1;
res.write(`data: ${JSON.stringify({ type: 'text', content })}\n\n`);
setTimeout(send, 40); // pace the stream; optional
} else {
res.write('data: [DONE]\n\n');
res.end();
}
};
send();
}You can also stream the whole message as a single chunk, then [DONE] — token-by-token just looks nicer.
Non-streaming reply
If the request body has stream: false, return a single JSON object:
{ "message": "your full reply text" }Rules
- Respond within 30 seconds, with body ≤ 1 MB and status 200. The whole window — including a full SSE stream — must complete in time, or the run fails with a timeout and cannot be answered afterward.
event-streamevents are separated by a blank line (\n\n).- For a real assistant, generate
replyTextwith your LLM. For non-conversational triggers you can simply acknowledge and do the work via the API.
Act as the agent via the API
For triggers that carry a target (assigned, mention) — or any time the agent should do something — call the GraphQL API with the agent's api_token and the API-Version: dev header. Actions run under the agent's identity and permissions.
Post an update on the assigned item:
await mondayApi(agentApiToken, `
mutation ($itemId: ID!, $body: String!) {
create_update(item_id: $itemId, body: $body) { id }
}`,
{ itemId: String(payload.itemId), body: 'On it ✅ — reviewing the item now.' }
);Reply within an existing update thread (use parent_id):
mutation ($parentId: ID!, $body: String!) {
create_update(parent_id: $parentId, body: $body) { id }
}Sanity-check the token and identity:
curl -X POST https://api.monday.com/v2 \
-H "Authorization: <agent-api-token>" \
-H "API-Version: dev" \
-H "Content-Type: application/json" \
-d '{"query":"{ me { id name account { id } } }"}'Remember: the agent needs explicit board access (Grant board access) or writes will fail even though the token is valid.
Disconnect an agent
Revokes the agent's token and deletes the agent. Uses the owner token. See disconnect_external_agent.
mutation {
disconnect_external_agent(id: 1234567890) {
success
}
}To rotate lost credentials, disconnect and then reconnect.
Full reference implementation (Express)
A minimal end-to-end webhook receiver: verify → parse → respond (SSE) → (optionally) act.
import express from 'express';
import crypto from 'crypto';
const app = express();
// Capture the RAW body for signature verification.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
// Look these up per agent_id from your secure store (set at connect time).
async function getAgentCreds(agentId) {
// return { name, signingSecret, apiToken } | null
}
function verifySignature(signingSecret, rawBody, headers) {
const ts = headers['x-monday-timestamp'];
const recv = headers['x-monday-signature'];
if (!ts || !recv) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', signingSecret)
.update(`${ts}.${rawBody}`).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(recv);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
async function mondayApi(token, query, variables = {}) {
const res = await fetch('https://api.monday.com/v2', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: token, 'API-Version': 'dev' },
body: JSON.stringify({ query, variables }),
});
return res.json();
}
app.post('/agent/webhook', async (req, res) => {
const agentId = req.headers['x-monday-agent-id'];
const rawBody = req.rawBody ? req.rawBody.toString('utf8') : JSON.stringify(req.body || {});
const body = req.body || {};
const triggerType = body.triggerType || 'unknown';
const wantsStream = body.stream !== false; // default: stream
if (!agentId) return res.status(400).json({ error: 'missing x-monday-agent-id' });
const creds = await getAgentCreds(agentId);
if (creds?.signingSecret && !verifySignature(creds.signingSecret, rawBody, req.headers)) {
return res.status(401).json({ error: 'invalid signature' });
}
const text = body.payload?.text || '';
// --- Generate the reply (plug in your LLM here) ---
const replyText = `Hi! I'm ${creds?.name || 'the agent'} 👋 You said: "${text}"`;
// --- assigned/mention: act on the item via the API ---
if (triggerType === 'assigned' && body.payload?.itemId && creds?.apiToken) {
await mondayApi(creds.apiToken, `
mutation ($itemId: ID!, $b: String!) { create_update(item_id: $itemId, body: $b) { id } }`,
{ itemId: String(body.payload.itemId), b: replyText });
}
// --- Respond so the chat renders the reply ---
if (!wantsStream) {
return res.status(200).json({ message: replyText });
}
res.status(200);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();
for (const word of replyText.split(' ')) {
res.write(`data: ${JSON.stringify({ type: 'text', content: word + ' ' })}\n\n`);
}
res.write('data: [DONE]\n\n');
res.end();
});
app.listen(process.env.PORT || 8080);Deployment notes (monday-code)
If you host the agent on monday-code, the most common failure is using the wrong host for the callback URL.
- Behind the monday-code proxy, your server's request
host/ Expressreq.hostnameis the internal Cloud Run host, e.g.xxxxx---service-….a.run.app. That host returns403 Forbiddento monday.com — webhooks never arrive. - Always use the public host:
https://<deploy>-service-…-<region>.monday.app/....- Derive it from the frontend
origin(the iframe is served from the public host), thex-forwarded-hostheader, or an explicitPUBLIC_BASE_URL— and explicitly reject any*.run.appvalue.
- Derive it from the frontend
- The public deploy host changes on every deployment. If you set the callback to a specific deploy host, you must update it after each deploy. Prefer the stable live alias (
https://live1-service-…monday.app/...) so the callback survives redeploys. - OAuth/redirect URLs (if your app uses OAuth) must include each public host you serve from.
Troubleshooting
| Symptom | Cause / Fix |
|---|---|
| "Something went wrong while running the agent." | You returned a plain JSON body for a chat trigger. Respond with SSE (text/event-stream, {type:"text",content:…} chunks, data: [DONE]). |
| Webhook never arrives | Callback points at an unreachable host — *.run.app (returns 403), localhost, or an http URL. Use a public HTTPS *.monday.app host. |
Authorization error on update_custom_agent | You used the agent's api_token. This mutation needs the owner token. |
| Signature verification fails | HMAC must be over `${timestamp}.${rawBody}` with the raw body and the signing_secret (not api_token). |
| Agent "has access but can't edit items" | Grant READ_WRITE via add_agent_resource_access — your own access doesn't carry over. |
| Agent never runs | It's inactive — call activate_agent. |
| Secrets lost | signing_secret / api_token are shown once. disconnect_external_agent and reconnect to rotate. |
connect_external_agent_sync times out | It's synchronous (~25s). Set client timeout ≥ 40s. |
Quick reference
CREATE connect_external_agent_sync(input:{custom:{name, callback_url}}) → agent_id, signing_secret, api_token [owner token, ~25s]
UPDATE update_custom_agent(input:{agent_id, name?, callback_url?}) → success [owner token]
ACTIVATE activate_agent(id) → success [owner token]
ACCESS add_agent_resource_access(id, resource_id, scope_type, permission_type) → success [owner token]
DELETE disconnect_external_agent(id) → success [owner token]
WEBHOOK POST {callback_url}
headers: x-monday-agent-id, x-monday-signature, x-monday-timestamp
body: { event:"agent_triggered", triggerType, payload:{text, ...}, timestamp }
verify: HMAC-SHA256(signing_secret, `${timestamp}.${rawBody}`) === x-monday-signature
respond: SSE data: {"type":"text","content":"..."}\n\n ... data: [DONE]\n\n
(or { "message": "..." } when request has stream:false)
ACT POST https://api.monday.com/v2 Authorization: {api_token} API-Version: dev [act as the agent]All agent API calls require the header
API-Version: devwhile the feature is pre-release.
Join our developer community!If you have questions or need help with custom agents, visit the monday.com developer community.
