External Agent Contract
Audience: developers building an external agent that plugs into a Nembl workflow as a first-class participant.
External agents register via Admin → External Agent Connections, then
get attached to an Agent with the External backend
(overview). When a workflow
phase dispatches the agent, Nembl calls your endpoint with the envelope
below, waits for your reply, and turns your AgentResult into a
workflow-side Recommendation.
Two transports are supported: WEBHOOK (HTTPS POST) and MCP (Model Context Protocol over HTTPS). A2A is reserved for a future release.
1. Lifecycle (what your endpoint sees)
┌─ Nembl proxy ─────────────────────────────────────────────────────┐
│ 1. Receives the dispatch envelope from the workflow coordinator │
│ 2. Fetches your endpoint's auth secret from your Nembl vault │
│ 3. Signs the request per the endpoint's authType │
│ 4. POSTs (webhook) or invokes a tool (MCP) on your endpoint │
│ 5. Awaits your response — timeout = endpoint.timeoutMs (def. 60s) │
│ 6. Maps your AgentResult → workflow-side Recommendation │
└───────────────────────────────────────────────────────────────────┘Your endpoint is synchronous within the timeout window. There is no
callback URL — you respond on the request that was sent. Cancellation is
communicated by Nembl closing the HTTP connection; honour AbortController
semantics if your runtime supports them.
2. Webhook transport
Request
POST <your endpoint URL>
Content-Type: application/json
Nembl-Invocation-Id: <invocationId>
Nembl-Timestamp: <unix-seconds>
<auth header per authType — see §4>Request body (ExternalDispatchEnvelope):
{
"invocationId": "ckxxx...", // Stable PK; idempotency key
"agentId": "agt_...", // The Nembl Agent dispatching to you
"companyId": "co_...", // Tenant scope
"instanceId": "wfi_...", // Workflow instance
"phaseId": "wfp_...", // Workflow phase
"workflowId": "wf_...",
"autonomyLevel": "suggest" | "act_with_approval" | "fully_autonomous",
"variables": { /* workflow instance variables at dispatch */ },
"capabilities": { /* the capabilities you declared at registration */ },
"assignmentConfig": null | { /* RACI engagement config — read-only */ }
}invocationId is stable across retries — use it as your idempotency key.
No internal IDs or tokens cross this boundary.
Response
Return HTTP 200 OK with an AgentResult body:
{
"analysis": "Short human-readable summary of what the agent decided.",
"reasoning": "Optional longer rationale.",
"proposedActions": [
{
"type": "add_comment" | "create_task" | "update_variables" | ...,
"payload": { /* per-action shape — see §5 */ }
}
],
"tokenCount": 1234, // Optional usage telemetry
"model": "gpt-4o-mini", // Optional
"provider": "openai" // Optional
}Allowed type values in proposedActions are restricted to the
capabilities.actions allowlist you declared at registration. Actions
outside the allowlist are silently dropped and surfaced as a warning in the
Nembl admin UI.
Error response
Two paths — pick whichever fits your runtime:
(a) Return 200 OK with an error envelope:
{
"errorCode": "EXTERNAL_PROVIDER_ERROR",
"errorMessage": "Upstream LLM returned 503",
}(b) Return an HTTP error. Nembl maps by status code:
| Your HTTP status | Nembl errorCode |
|---|---|
| 401 / 403 | EXTERNAL_AUTH_FAILED (notifies your customer's admins) |
| 4xx (other) | EXTERNAL_PROVIDER_ERROR |
| 5xx | EXTERNAL_PROVIDER_ERROR |
No response within timeoutMs | EXTERNAL_TIMEOUT |
| Non-JSON or malformed body | EXTERNAL_INVALID_RESPONSE |
3. MCP transport
Your endpoint is an MCP server speaking over HTTPS (Streamable HTTP transport). Nembl's proxy acts as an MCP client:
- HTTPS transport only (no stdio — Nembl can't launch local processes on your side).
- Single-tool dispatch per invocation — the connection's
mcpToolNamedeclares which tool to call. - Auth:
BEARERorAPI_KEY_HEADER(HMAC is webhook-only).
Flow
- Nembl opens an MCP session to your endpoint URL.
- Lists tools (
tools/list); cached in-Lambda for 5 minutes per endpoint. - Calls the configured tool (
tools/call) with{ envelope: <envelope> }as the argument. - Maps your tool result back to
AgentResult.
Tool result mapping
- If
structuredContentis present and shaped likeAgentResult→ used directly. Recommended path. - If
structuredContent.proposedActions: [...]is present → used for actions;analysisfalls back to joinedcontenttext blocks. - If only
contenttext blocks → joined intoAgentResult.analysis, zero proposed actions. isError: true→EXTERNAL_PROVIDER_ERROR.
Recommendation: return structuredContent matching the AgentResult
shape from §2.
4. Auth payloads
authType is declared per endpoint at registration and stored in a Nembl
vault you link to the endpoint. Nembl fetches the secret value at runtime —
secret values never appear in CloudTrail or EventBridge events.
BEARER
Vault keys: { "bearer_token": "<token>" }
Authorization: Bearer <bearer_token>API_KEY_HEADER
Vault keys: { "header_name": "X-Api-Key", "header_value": "<key>" }
<header_name>: <header_value>HMAC (webhook only, recommended)
Vault keys: { "signing_secret": "<shared-secret>" }
Nembl-Timestamp: <unix-seconds>
X-Nembl-Signature: t=<unix-seconds>, v1=<hex>Signature computation:
signed_payload = <unix-seconds> + "." + <raw-request-body>
signature = hex( hmac_sha256(signing_secret, signed_payload) )Verification on your side:
- Compute the same signature using your stored
signing_secret. - Compare with constant-time equality.
- Reject if
|now - unix-seconds| > 300(5-minute replay window).
Working TypeScript verifier:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyNemblSignature(opts: {
signingSecret: string;
rawBody: string;
headers: Record<string, string>;
}): boolean {
const header = opts.headers["x-nembl-signature"] ?? "";
const match = /t=(\d+),\s*v1=([0-9a-f]+)/.exec(header);
if (!match) return false;
const [, ts, sig] = match;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac("sha256", opts.signingSecret)
.update(`${ts}.${opts.rawBody}`)
.digest("hex");
if (expected.length !== sig.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}NONE
No auth. Suitable only for endpoints already protected at the network layer (e.g. private mesh peering). Avoid unless you have a specific reason.
5. Action payload shapes
proposedActions[*].payload matches Nembl's workflow-action vocabulary:
| Type | Payload shape |
|---|---|
add_comment | { "body": "<markdown>", "visibility": "internal" | "customer" } |
create_task | { "title": "...", "description": "...", "assigneeUserId": "..." } |
update_variables | { "<varName>": <value>, ... } |
request_approval | { "approverUserId": "...", "reason": "..." } |
escalate | { "reason": "...", "level": "team" | "manager" } |
advance_phase | { "toPhaseId": "..." } |
Proposed actions flow through Nembl's autonomy gates — autonomyLevel: "suggest" always renders as a human-acceptable Recommendation;
act_with_approval requires a single accept click; fully_autonomous
applies immediately. Your endpoint never bypasses autonomy.
6. Idempotency and retries
invocationIdis the idempotency key. Nembl does not retry external dispatches; a single network failure surfaces asEXTERNAL_PROVIDER_ERROR.- Write idempotent handlers anyway — keyed on
invocationId. - Cancellation: if a human cancels mid-flight, Nembl closes the HTTP
connection. Honour
AbortSignalsemantics if your runtime supports them.
7. Health checks
Every 15 minutes Nembl probes your endpoint:
- Webhook:
POST <url>with body{ "type": "nembl.healthcheck" }and the configured auth headers. Respond200with body{ "ok": true }. - MCP: Standard MCP
initializehandshake; success counts as healthy.
Three consecutive failures flip the endpoint to FAILED_HEALTH_CHECK and
notify your customer's Nembl admins. First success after failure flips it
back to ACTIVE.
8. Error codes (Nembl-side)
| Code | When | Customer notification |
|---|---|---|
EXTERNAL_AUTH_FAILED | Your endpoint returned 401/403 | Yes — customer admins |
EXTERNAL_PROVIDER_ERROR | Other 4xx/5xx or network failure | No (logged) |
EXTERNAL_INVALID_RESPONSE | Couldn't parse AgentResult from response | No (logged) |
EXTERNAL_TIMEOUT | No response within endpoint.timeoutMs | No (logged) |
EXTERNAL_ENDPOINT_INACTIVE | Pre-dispatch — endpoint status was not ACTIVE | No |
EXTERNAL_HEALTH_CHECK_FAILED | Pre-dispatch — health-check cron flipped status | Yes — customer admins |
PLAN_LIMIT_EXCEEDED | Pre-dispatch — customer's plan doesn't include EXTERNAL dispatches | Yes — customer admins, one-time |
These appear in the Nembl admin UI under the agent's recent invocations, and in the workflow audit log.
9. Working reference receivers
Two minimal reference implementations live in the Nembl repo — clone and run them locally for testing:
- Webhook:
docs/examples/external-agent-webhook/(opens in a new tab) — Node 22, ~100 LOC, dependency-freehttpserver. Demonstrates HMAC verification + the response shape. - MCP:
docs/examples/external-agent-mcp/(opens in a new tab) — Node 22,@modelcontextprotocol/sdkv1.29, stateless Streamable HTTP server with a singletriage_requesttool returning a deterministicadd_commentAgentResult. Bearer-token auth + optionalRESPONSE_DELAY_MSfor exercising timeout/cancel paths.
10. Capabilities declaration
Your endpoint declares its capabilities at registration time via the
"Capabilities" multi-select. This is an allowlist — proposed actions
outside the declared set are dropped server-side. Declared capabilities
also flow into the dispatch envelope as capabilities so your handler can
branch on them.
Available actions today:
add_comment, create_task, assign_task, update_task_status,
update_variables, request_approval, escalate, advance_phaseDeclare only what your endpoint actually does. Future action additions will land here without breaking existing endpoints.