Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/hunvreus/heypi/llms.txt

Use this file to discover all available pages before exploring further.

The webhook adapter exposes a generic JSON HTTP interface so that internal systems, scripts, and pipelines can send messages to your heypi agent over plain HTTP — no chat platform required. It is async-first, supports callback URLs for fire-and-forget workflows, and includes a sync mode for short blocking requests. The adapter registers routes on heypi’s shared Node HTTP listener and is inbound-only: scheduled jobs cannot target webhook adapters.

Setup

import { createHeypi, runHeypi, webhook } from "@hunvreus/heypi";

const app = createHeypi({
  state: { root: "./state" },
  http: {
    host: "127.0.0.1",  // Bind to loopback only; set "0.0.0.0" behind a proxy
    port: 3000,
  },
  adapters: [
    webhook({
      name: "internal",
      secret: process.env.HEYPI_WEBHOOK_SECRET!,
      replyHosts: ["internal.example.com"],  // Allowlist for callback URLs
    }),
  ],
  // agent: ...
});

await runHeypi(app);
The shared HTTP listener binds to 127.0.0.1:3000 by default. Set http.host: "0.0.0.0" only when an external reverse proxy, API gateway, or firewall handles public exposure.

Routes

When name is "internal", the following routes are registered:
MethodPathPurpose
POST/webhook/internalStart a new thread or send a threadless message
POST/webhook/internal/messagesAlias for the above
POST/webhook/internal/threads/:threadId/messagesFollow up on an existing thread
GET/webhook/internal/threads/:threadId/runs/:runIdPoll for run status
If name is omitted, the default is webhook and the prefix becomes /webhook/webhook. When running multiple webhook adapters in the same app, give each a unique name.

Authentication

Every request must include the shared secret in one of two ways:
authorization: Bearer <secret>
x-heypi-secret: <secret>
Use a long random string (at least 32 bytes of entropy). Treat it like an API token — rotate it if it is ever exposed.

Limits

SettingDefaultDescription
maxBodyBytes1,000,000Maximum request body size (bytes)
maxInFlight32Maximum concurrent in-flight runs
Webhook payloads are for short JSON messages, not file uploads. The maxInFlight cap is in-process only — it is not a distributed rate limiter.

Request Body Fields

Every message request body may include:
FieldTypeRequiredDescription
textstringThe message text to process. Must be non-empty after trimming.
userstringCaller identity (used in logs and for cancel authorization).
threadIdstringExisting thread to follow up on. Omit to start a new thread.
eventIdstringCaller-supplied idempotency key; defaults to the generated runId.
dataunknownArbitrary extra data forwarded to the agent handler.
replyUrlstringCallback URL for the completed run result (requires replyHosts).
syncbooleanWait for the result in the same HTTP response (see Sync mode).
timeoutMsnumberPer-request sync timeout in milliseconds (capped at 30 000).

Start a Thread

Omit threadId to create a new server-side thread. Heypi generates and returns an opaque threadId. Store it on the caller side for follow-ups.
curl -X POST http://localhost:3000/webhook/internal/messages \
  -H "authorization: Bearer $HEYPI_WEBHOOK_SECRET" \
  -H "content-type: application/json" \
  -d '{"user": "alice@example.com", "text": "Start incident review"}'
Response (202 Accepted):
{
  "ok": true,
  "threadId": "whth_...",
  "runId": "xxxxxxxx-...",
  "status": "running"
}

Follow Up on a Thread

Send a follow-up message to an existing thread using the threadId from the initial response:
curl -X POST http://localhost:3000/webhook/internal/threads/<threadId>/messages \
  -H "authorization: Bearer $HEYPI_WEBHOOK_SECRET" \
  -H "content-type: application/json" \
  -d '{"user": "alice@example.com", "text": "What changed in the last hour?"}'

Check Run Status

Poll for the result of a specific run:
curl http://localhost:3000/webhook/internal/threads/<threadId>/runs/<runId> \
  -H "authorization: Bearer $HEYPI_WEBHOOK_SECRET"
Run state is persisted to heypi’s store, so a restarted process can still serve status requests for prior runs as long as it uses the same state directory.

Async, Callback, and Sync Modes

All message endpoints return 202 Accepted immediately while the agent turn runs in the background. Poll the status endpoint to retrieve the final result.
{
  "ok": true,
  "threadId": "whth_...",
  "runId": "xxxxxxxx-...",
  "status": "running"
}

Approval Integration

When a turn is waiting for a human approval, the run response includes structured approval data that your caller can use to build UI buttons or display a confirmation prompt:
{
  "ok": true,
  "threadId": "whth_...",
  "runId": "xxxxxxxx-...",
  "status": "pending_approval",
  "approval": {
    "id": "appr_...",
    "callId": "call_...",
    "reason": "Run deployment command.",
    "details": [
      { "label": "Target", "value": "prod-web-1" },
      { "label": "Command", "value": "systemctl restart app", "format": "code" }
    ]
  }
}
To approve or deny, post the corresponding text command back to the same thread:
# Approve
curl -X POST http://localhost:3000/webhook/internal/threads/<threadId>/messages \
  -H "authorization: Bearer $HEYPI_WEBHOOK_SECRET" \
  -H "content-type: application/json" \
  -d '{"user": "alice@example.com", "text": "approve appr_..."}'

# Deny
curl -X POST http://localhost:3000/webhook/internal/threads/<threadId>/messages \
  -H "authorization: Bearer $HEYPI_WEBHOOK_SECRET" \
  -H "content-type: application/json" \
  -d '{"user": "alice@example.com", "text": "deny appr_..."}'
Other supported text commands:
approvals                    # List pending approvals in the thread
approve <approval-id>        # Approve by approval ID
deny <approval-id>           # Deny by approval ID
status                       # Current thread status
status <call-id>             # Status for a specific tool call
cancel <turn-id-or-trace>    # Cancel the active run
Cancel requests are accepted only from the webhook user that started the active run, plus any configured approval.approvers.

Full Example

The following is the complete examples/webhook-notes entry point — a minimal note-taking agent exposed over the webhook adapter:
import { agentFrom, coreTools, createHeypi, runHeypi, tool, webhook, workspace } from "@hunvreus/heypi";
import { Type } from "@sinclair/typebox";

const saveNote = tool<{ note: string; topic?: string }>({
  name: "save_note",
  description: "Append a short note to local Markdown.",
  parameters: Type.Object({
    note: Type.String({ description: "Concise note to save." }),
    topic: Type.Optional(Type.String({ description: "Optional topic label." })),
  }),
  execute: async ({ note, topic }) => {
    // ...write to file
    return "note saved";
  },
});

const app = createHeypi({
  state: { root: "./state" },
  http: {
    host: "127.0.0.1",
    port: Number(process.env.HEYPI_WEBHOOK_PORT ?? 3000),
  },
  adapters: [
    webhook({
      name: "notes",
      secret: process.env.HEYPI_WEBHOOK_SECRET!,
    }),
  ],
  agent: agentFrom("./agent", {
    model: "openai/gpt-5-mini",
    tools: [...coreTools({ bash: false, write: false, edit: false }), saveNote],
  }),
  runtime: { root: workspace("./workspace") },
});

await runHeypi(app);

Security Notes

The webhook adapter does not perform distributed rate limiting. The maxInFlight setting is a single-process guard only. For production deployments, place the webhook behind a reverse proxy or API gateway that enforces rate limits, IP allowlists, and TLS termination.
  • Never expose the webhook port directly to the public internet without a proxy.
  • Rotate secret immediately if it is ever leaked or appears in logs.
  • replyHosts is an exact-hostname allowlist. Wildcard or suffix matching is not supported — list each host explicitly.
  • Webhook is inbound-only. It does not implement adapter.send(), so scheduled jobs and heartbeat jobs cannot target webhook adapters as a delivery channel.

Common Failures

The authorization: Bearer header or x-heypi-secret header is missing, incorrect, or the secret does not match what was passed to webhook({ secret }). Verify HEYPI_WEBHOOK_SECRET is set correctly in both the server environment and your curl/client code.
The request body is missing the text field or it is empty after trimming. Every message request must include a non-empty text string.
The hostname in replyUrl is not listed in replyHosts. Add the hostname to the replyHosts array in your adapter config and redeploy.
The process has reached maxInFlight concurrent runs. Either increase maxInFlight, reduce concurrency in the caller, or scale horizontally.
The threadId or runId does not exist in the current state store. This can happen if the state directory was deleted, the app was restarted with a different state.root, or the IDs were typed incorrectly.

Build docs developers (and LLMs) love