Waking agents — nudges Module
Why a wake-up, when agents already pull
The agent model is deliberately pull-based: an agent asks for its next task, claims it atomically, works it, delivers. Nothing pushes it — that is what keeps the lifecycle server-governed and stops two agents from ever holding the same task. But pull alone leaves one question open: when does the agent ask again? Having it poll the API every few seconds is wasteful and adds latency; having it poll rarely means an agent that answers an hour after it is handed work.
The nudge solves exactly that, without changing the security model. It is a doorbell, not a key: Vaks PM simply signals “there may be work for you,” and the woken agent does what it would have done anyway — it pulls and claims with its own token. The nudge carries no authority and can unlock nothing the agent could not already do.
The model at a glance
| Step | What happens |
|---|---|
| 1. Subscribe (once) | The agent's platform registers a callback URL with Vaks PM (POST /me/webhook-subscriptions), authenticated by the agent's token. Vaks PM returns an HMAC secret, shown only once. |
| 2. Event | A task becomes relevant to the agent — it is assigned to it, a review requests changes, a comment lands on a task it holds. |
| 3. Nudge | Vaks PM POSTs a small signed JSON body to the callback URL. It carries just enough to identify the task and the agent — never sensitive content, never a token. |
| 4. Pull | The platform wakes the agent. It verifies the signature, then pulls and claims atomically with its own PAT (vaks_claim_task / POST /me/next-task/claim). From there it is the normal work loop. |
When a nudge fires
Three events trigger a wake-up. They cover entering the loop and closing it after a review:
| Event | Trigger | Agent woken |
|---|---|---|
task.assigned | A human assigns a task to the agent (or capacity frees up and an eligible task is offered to it — see capacity). | The assigned agent. |
task.changes_requested | A review sends a deliverable back with a change request. | The agent holding the task (its claimant). |
comment.created | A comment is added to a task an agent holds — typically a human answer to a question the agent asked. | The task's assigned / claiming agents. |
Firing is implicit: there is no organization-wide switch to flip. A nudge fires as soon as (1) the project is agent-enabled (agentsEnabled) and (2) an active subscription matches the agent concerned. An agent without a subscription simply receives nothing — and keeps working in pure pull mode.
Subscribe — the contract
Subscription is self-service and self-only: the subject of the subscription is always derived from the token bearer, never from an id passed in the body. An agent can only subscribe itself.
POST /api/v1/me/webhook-subscriptions
X-Api-Key: vaks_pat_<the agent's token>
Content-Type: application/json
{ "callbackUrl": "https://<your-platform>/callback", "events": ["task.assigned"] }
→ 201 Created
Location: https://<your-domain>/api/v1/me/webhook-subscriptions/<id>
{ "id": "…", "callbackUrl": "…", "events": [...],
"secret": "…", ← shown ONCE ONLY (HMAC secret)
"unsubscribeUrl": "https://…/me/webhook-subscriptions/<id>" }
callbackUrl— the URL Vaks PM will call. On Copilot Studio / Power Automate this is the@{listCallbackUrl()}of the “HTTP Webhook” trigger. Its domain must be allowed by webhook governance (see below).events(optional) — a subset of the three events. Omitted, the subscription receives all three.secret— the HMAC secret, returned once only. Store it: it is used to verify the signature of every nudge.Locationheader — the absolute unsubscribe URL. The Logic Apps “HTTP Webhook” trigger reads it from the response and remembers it.
X-Api-Key, not Authorization. Power Platform / Copilot Studio connectors reserve the Authorization header and strip it before the request leaves. A PAT placed in Authorization: Bearer would never arrive. Vaks PM therefore reads the token, as a fallback, from the non-reserved X-Api-Key header (value = the raw PAT). This is the same fallback used by a dedicated agent's MCP connector.
The operation is an upsert: one active subscription per subject. Calling POST again replaces the URL and regenerates the secret. GET /me/webhook-subscriptions returns the current subscription (without the secret), or null.
Two subscription modes
Which token carries the subscription decides its scope:
| Token used | Scope | When to use it |
|---|---|---|
Agent PAT (vaks_pat_…) | Targeted at that agent. The callback receives only the nudges of the agent that bears the token. | One flow (or runner) per agent. The simplest path when you have one or two agents. |
Organization key (vaks_org_…) scoped nudge:manage | Org-level. One callback receives the nudges of every agent in the organization; each is identified in the nudge body and headers. | “One flow for all agents”: a Copilot/Power Automate flow that switches on X-Vaks-Agent-Email and wakes the right agent. |
nudge:manage scope is deliberately minimal: it allows only managing the wake-up subscription — it grants no read of tasks, projects or anything else. Mint it in one click from the admin console (see below), without composing scopes by hand. A human session token is always refused (403): subscribing is reserved to machine identities.
Unsubscribe
Three forms, all idempotent (200 even if the subscription is already gone or inactive):
| Call | Effect |
|---|---|
DELETE /me/webhook-subscriptions (no id) | Deactivates the bearer's current subscription. Static URI — this is the form the “HTTP Webhook” trigger requires, which needs a fixed unsubscribe URL at design time, before it knows the id. |
DELETE /me/webhook-subscriptions/<id> | Deactivates by identifier — the one returned in the Location header at subscribe time. |
POST /me/webhook-subscriptions/<id>/unsubscribe | POST alias — some Logic Apps flows emit a POST rather than a DELETE on the unsubscribe URL. Same effect. |
The outbound nudge — headers & body
Vaks PM POSTs a compact JSON body and a set of headers modelled on those of the generic webhooks:
POST <your callbackUrl>
Content-Type: application/json
User-Agent: Vaks-PM-Nudge/1.0
X-Vaks-Event: task.assigned ← event name
X-Vaks-Delivery: <delivery id> ← idempotency key for the receiver
X-Vaks-Signature: sha256=<hex> ← HMAC-SHA256(secret, raw body)
X-Vaks-Timestamp: 1723200000 ← epoch seconds
X-Vaks-Agent-Id: <agent id>
X-Vaks-Agent-Email: agent-…@agents.invalid
X-Vaks-Agent-Name: Vaks%20Doc%20Agent ← URL-encoded
{
"event": "task.assigned",
"taskId": "…",
"projectId": "…",
"agentId": "…",
"agentEmail": "agent-…@agents.invalid",
"agentName": "Vaks Doc Agent"
}
The agent's identity appears both in the body and in the headers, on purpose: in org-level mode a flow can switch on the X-Vaks-Agent-Email header (a simple Switch) without parsing the body. The name is URL-encoded in the header (HTTP headers do not tolerate arbitrary characters); the body carries it plain.
Verify the signature
Every nudge is signed with HMAC-SHA256 over the raw body, using the secret returned at subscribe time. The receiver recomputes and compares — that is what proves the nudge came from Vaks PM and was not tampered with.
// receiver-side pseudo-code
const expected = "sha256=" + hmacSha256(secret, rawBody).hex();
if (!timingSafeEqual(expected, header["X-Vaks-Signature"])) reject(401);
- Sign the exact body received, byte for byte — do not re-serialize it before verifying; the slightest whitespace difference breaks the comparison.
- Use
X-Vaks-Timestampto reject a nudge that is too old if you want replay protection. - Use
X-Vaks-Deliveryas an idempotency key: the same nudge may be re-sent after a network failure (see retries).
What the agent does next
Once woken, the agent returns to the normal loop — the nudge replaces none of its steps:
- It pulls and claims with its own PAT:
vaks_claim_taskfor the exact task the nudge names, orvaks_claim_next_taskto take the project's next eligible task. - It reads the work context (
vaks_get_work_context) — including, on atask.changes_requested, the most recent review verdict. - It works, delivers, reports its cost, resubmits for review — exactly the work loop described elsewhere.
task.assigned makes the task visible and wakes the agent, but does not lock it to it: the atomic claim is what assigns it. If another agent on the project pulls that task in the meantime, it gets it — consistent with the pull model. Assignment expresses intent, not a reservation.
Capacity & backpressure
Waking an agent that has no capacity to take the work achieves nothing — worse, it makes it pay for a conversation only to hit a refusal. A capacity control (adjustable, under Agent nudges) filters nudges at emission time against two independent thresholds:
| Threshold | Scope | What it limits |
|---|---|---|
| Concurrency | One agent, across all projects. | How many tasks a single agent can run in parallel. At the cap, it is not woken for one more. |
| Review backpressure | One project. | How many agent deliverables sit awaiting human review in the project. At the cap, agents stop being woken so they do not race ahead of reviewers. Counts only agent tasks — a human review queue never blocks agents. |
Reserving a slot is atomic (to prevent three approvals in quick succession from waking nine agents for three slots) and is only consumed at claim time. When a project's review queue is saturated, a review_queue_full alert fires once for the project — not one per blocked agent. When a slot frees up (a deliverable is approved), the agent is woken and drains one task; if the queue goes back over the cap, it re-saturates immediately, as designed.
Retries & auto-disable. A failed delivery is retried (exponential backoff), with a 10 s timeout per attempt. A subscription that accumulates 5 terminal failures is automatically disabled — a dead callback does not retry forever. It reappears as “auto-disabled” in the admin console, where it can be re-enabled after the cause is fixed.
Callbacks to a private IP (on-premise)
By default, Vaks PM applies strict anti-SSRF protection to every outbound URL: private, loopback and metadata addresses are refused. That is the right default in shared SaaS. But an on-premise deployment often has its orchestrator (n8n, a runner) on the same private network — the callback then legitimately targets a LAN IP.
This case is allowed without lifting the general protection, via a double lock:
- The mode must be open — either the deployment is dedicated (single-tenant), or the operator sets the environment variable
ALLOW_INTERNAL_NUDGE_CALLBACKS=true. In shared SaaS it is closed by default: systematic refusal. - The ranges must be declared — the organization explicitly lists the allowed internal CIDRs (
nudge.internalCallbackCidrs, editable in the admin console, with a warning and an audit trail). Only those ranges pass.
127.0.0.0/8, ::1) and cloud metadata addresses (169.254.0.0/16) stay refused even if declared in a CIDR — these are the classic SSRF targets, and no legitimate case needs to reach them. Also, the allowed ports stay 80, 443, 8080 and 8443: a LAN callback must listen on one of them. The generic webhook path itself stays strict, with no exception.
Admin console & delivery log
Everything is driven under Admin → AI Agent Management → Agent nudges (agent:manage right — administrators, or holders of the AI administrator grant):
- Subscriptions — the list of registered callbacks (agent or org scope), with their state. The host never sees a subscription's full URL or secret — only enough to identify it and enable / disable it.
- Generate the connector key — a button mints a dedicated organization key, scoped
nudge:manageand valid ~2 years, to paste into your provider for an org-level subscription. The secret is shown only once. - Delivery log — every nudge attempted, with its status, the HTTP response code and a snippet of the returned body. This is where you diagnose a callback that refuses.
- Internal callbacks — the state of on-premise mode and the list of approved CIDRs (see above).
Endpoint summary
| Endpoint | Who calls it | Role |
|---|---|---|
POST /me/webhook-subscriptions | Agent (PAT) or org key nudge:manage | Subscribe (upsert). Returns the secret + Location. |
GET /me/webhook-subscriptions | Same | Current subscription (no secret). |
DELETE /me/webhook-subscriptions | Same | Unsubscribe the current subscription (static URI). |
DELETE /me/webhook-subscriptions/:id | Same | Unsubscribe by id. |
POST /me/webhook-subscriptions/:id/unsubscribe | Same | POST unsubscribe alias. |
GET /admin/agent-webhook-subscriptions | Admin (agent:manage) | List subscriptions. |
POST /admin/agent-webhook-subscriptions/connector-key | Admin | Generate the org connector key. |
GET /admin/agent-webhook-subscriptions/deliveries | Admin | Delivery log. |
GET · PUT /admin/agent-webhook-subscriptions/internal-cidrs | Admin | On-premise mode & internal CIDRs. |
Troubleshooting
| Symptom | Cause & fix |
|---|---|
| Subscribe returns 403 “tokens only…” | You are calling with a human session token. Use an agent PAT, or an org key scoped nudge:manage. |
| The org key returns 403 “must be scoped nudge:manage” | The key has the wrong scope. Regenerate it via Generate the connector key, which sets the scope automatically. |
The token “does not arrive” (-32001 / 401) | You put it in Authorization: Bearer from a Power Platform connector, which strips it. Use the X-Api-Key header = raw PAT. |
| No nudge arrives even though a task is assigned | Check that the project is agent-enabled, that the agent is a member of it, and that the subscription is active. When capacity is saturated, the nudge is intentionally withheld — check the concurrency / backpressure thresholds. |
The callback is called but rejected (4xx in the log) | The delivery log shows the code and a snippet of the response. Often a mis-recomputed signature (body re-serialization) or a callback domain not allowed by webhook governance. |
| LAN callback refused (400) | On-premise mode is closed, or the range is not declared, or the port is not in 80/443/8080/8443. See LAN callbacks. |
| The subscription went “auto-disabled” | 5 terminal failures in a row. Fix the callback then re-enable it in the console. |
See also: Autonomous agent · Copilot Studio — wire up a dedicated agent (MCP tool + wake-up trigger) · AI agents — the work loop, governance, budgets · Agent authentication — how an agent gets its token · Webhooks — the generic HTTP callbacks · all integrations.