Skip to main content

Webhook events

VS Agent doesn't push state to its controller through the admin API — that's request/response. Instead, the agent POSTs events to a base URL you configure, every time something interesting happens (a new connection, a delivered message, a received message).

This is how the chatbot in the generic AI agent sees what users are saying. It's also how you wire up your own controller if you fork.

Configuration​

Set EVENTS_BASE_URL on the VS Agent. Whenever an event fires, the agent does:

POST  ${EVENTS_BASE_URL}/<topic>
Content-Type: application/json

For example, EVENTS_BASE_URL=http://chatbot:3003 and a message arrives — the agent POSTs to http://chatbot:3003/message-received. The controller is responsible for exposing matching POST handlers.

EVENTS_BASE_URL formNotes
http://chatbot:3003Internal cluster DNS — the standard for helm install of the agent + chatbot together.
http://host.docker.internal:3003Docker Compose macOS — the chatbot is on the host, the VS Agent in a container.
http://192.168.1.50:3003Docker Compose Linux — chatbot on host, agent in container. Use the host's LAN IP.

The chatbot's scripts/setup.sh computes this for you.

Event topics​

Three topics fire today.

TopicEndpoint suffixWhen
connection-state-updated/connection-state-updatedA new connection is created or its state advances.
message-state-updated/message-state-updatedA previously-sent message changes delivery state.
message-received/message-receivedA peer sent the agent a message.

Each event POST body is a JSON object with type matching the topic and additional fields per topic.

connection-state-updated​

Fired on new connections and state changes (per the DID Exchange protocol).

{
"type": "connection-state-updated",
"connectionId": "b5079338-b96e-4197-98db-ada67db10895",
"invitationId": "a1c4d2…",
"state": "completed"
}
FieldTypeDescription
connectionIdUUIDStable identifier you'll use in /v1/message calls.
invitationIdUUIDThe invitation this connection started from (useful for tracking which QR / link a user came in on).
stateenumDID Exchange state: invitation-sent → request-received → response-sent → completed. The completed event is when you can start sending messages.

message-state-updated​

Fired on each delivery state change for messages you sent through /v1/message.

{
"type": "message-state-updated",
"messageId": "<id-from-POST-message-response>",
"timestamp": 1729785672,
"connectionId": "<connection-uuid>",
"state": "sent"
}
FieldTypeDescription
messageIdUUIDThe id you got back from POST /v1/message.
stateenumcreated, sent, delivered, viewed, errored.

Use these to confirm a critical message reached the user (e.g. the case-number message in a customer-service flow).

message-received​

Fired every time a peer sends the agent a message. This is the firehose — every text, every menu-select, every identity-proof-submit, every credential-reception. The chatbot consumes this to drive the LLM.

{
"type": "message-received",
"message": {
"type": "text",
"id": "<message-uuid>",
"connectionId": "<connection-uuid>",
"timestamp": 1729785672,
"threadId": "<thread-uuid-or-null>",
"content": "Hello, can you help me?"
}
}

The message field is the full DIDComm message envelope as documented in Admin API → Message types. You dispatch on message.type:

message.typeTypical handling
textFeed to the LLM.
mediaEither feed to a multimodal LLM or save and acknowledge.
menu-select, contextual-menu-selectRun the action bound to the menu item id.
identity-proof-submitVerify the proof; advance the auth flow.
credential-receptionUpdate internal state (done / declined / abandoned).
profileStash the user's locale, profile picture, etc.

Minimal controller — handle message-received​

The shape of a controller, in pseudo-code:

import express from 'express'
const app = express()
app.use(express.json())

// VS Agent sends this when something is received
app.post('/message-received', async (req, res) => {
const { message } = req.body
switch (message.type) {
case 'text':
await handleText(message)
break
case 'menu-select':
await handleMenuSelect(message)
break
// …
}
res.status(204).end()
})

app.post('/connection-state-updated', async (req, res) => {
const { connectionId, state } = req.body
if (state === 'completed') {
await sendWelcome(connectionId)
}
res.status(204).end()
})

app.post('/message-state-updated', (req, res) => {
// optional — log delivery state
res.status(204).end()
})

app.listen(3003)

Then point the VS Agent at it: EVENTS_BASE_URL=http://chatbot:3003.

The full version of this loop, including LLM dispatch, MCP tool calls, RBAC, memory, and statistics, is what hologram-generic-ai-agent-vs ships out of the box. Use the chatbot if you want all that; build your own controller only if you have a fundamentally different shape.

Subscriptions API (planned)​

The spec defines a /event-subscriptions REST surface that would let controllers register webhook URLs and filter by event type — but it's not yet implemented. Today, EVENTS_BASE_URL is the only knob. All three topics fire to that single base URL; you decide which to handle.

When subscriptions land, you'll be able to:

  • Filter by topic (only message-received).
  • Filter by attribute (only messages where connectionId == X).
  • Use either HTTP webhooks or a long-lived WebSocket channel.

For now, plan around the simpler model.

Operating notes​

  • Idempotency. Events can be re-delivered if the controller returns a non-2xx. Make handlers idempotent — keyed by messageId for messages, by connectionId+state for connections.
  • Backpressure. The agent pipelines events; a slow controller will queue them. Don't do heavy work synchronously in the handler — return 204 fast and process asynchronously.
  • Order. Events for the same connectionId arrive in order, but events across connections may interleave. Don't rely on global ordering.
  • Retries. A 5xx response triggers retry; a 4xx does not. Use 204 for "received and processed", 5xx only for transient failures.
  • Auth. There's no signed/authenticated payload yet. Run the controller on a private network or behind a reverse proxy with auth.

Next​