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 the API-Version: dev header.

🔬

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_agent mutation. 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_sync mutation. 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:

CredentialPurpose
signing_secretVerify that incoming webhooks really came from monday.com (HMAC).
api_tokenAct 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

FieldNotes
agent_idInternal monday.com agent ID.
signing_secretShown once. Store securely — used to verify webhooks.
api_tokenShown once. Store securely — used to act as the agent.
instructionsOptional setup notes.
❗️

signing_secret and api_token are 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 must disconnect_external_agent and 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_agent requires the owner's token, not the agent's api_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

HeaderExampleUse
x-monday-agent-id139988Which agent fired → look up its stored credentials.
x-monday-signaturesha256=a3a6ce…HMAC of the body — verify it.
x-monday-timestamp1782326623754Epoch milliseconds; part of the signed string.
content-typeapplication/jsonThe 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.app host — not your public URL; never derive your callback URL from it), x-forwarded-*, cf-*, via: 1.1 google, and user-agent: node. Sign and route on the x-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"
}
FieldTypeDescription
eventStringAlways agent_triggered for custom agent webhooks.
triggerTypeStringHow the agent was triggered: chat, assigned, mention, or unknown.
payloadObjectTrigger-specific data. Always includes text; other fields depend on triggerType.
timestampStringISO 8601 time the event occurred.

Trigger types

chat

The 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

The 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

The 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 an express.json({ verify }) hook. Use the signing_secret here — not the api_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 200 and Content-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 content pieces 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-stream events are separated by a blank line (\n\n).
  • For a real assistant, generate replyText with 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 / Express req.hostname is the internal Cloud Run host, e.g. xxxxx---service-….a.run.app. That host returns 403 Forbidden to 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), the x-forwarded-host header, or an explicit PUBLIC_BASE_URL — and explicitly reject any *.run.app value.
  • 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

SymptomCause / 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 arrivesCallback 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_agentYou used the agent's api_token. This mutation needs the owner token.
Signature verification failsHMAC 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 runsIt's inactive — call activate_agent.
Secrets lostsigning_secret / api_token are shown once. disconnect_external_agent and reconnect to rotate.
connect_external_agent_sync times outIt'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: dev while 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.