Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions docs/modules/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Helpthread module substrate

This is the operator- and module-author-facing guide to Helpthread's **module
substrate**: the HTTP surface that lets an out-of-process extension — a
draft-writing Assistant, a CRM sync, a notification bot, anything — connect
to a Helpthread deployment without any code living inside the core repo.

It documents the substrate as it is **shipped**, not as it was specified.
Where the two disagree, this guide follows the code and says so.

## Vocabulary (fixed — used the same way everywhere: schema, code, UI, docs)

- **Module** — an out-of-process Helpthread extension. Never called a
"plugin" (that word survives only inside the legal phrase *plugin
exception*, the AGPL §7 additional permission — it is not a synonym for
"module" anywhere in this substrate).
- **Agent** — a human support-staff user. Agents log in, see the inbox UI,
and approve or discard AI-drafted replies.
- **Assistant** — an AI actor principal. Assistants authenticate with their
own bearer token, read conversations, and post draft replies — they can
never send mail directly.

Do not conflate Agents and Assistants; the schema, the API, and the auth
model treat them as two entirely different kinds of caller with different
credentials and different capabilities.

## The three surfaces

| Surface | What it does | Guide |
|---|---|---|
| **Typed events** | The engine records eight kinds of domain event (a new conversation, inbound mail, a status change, a resolved draft, …) reliably, in the same transaction as the change they describe. | [webhooks.md](./webhooks.md) |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Remove the blanket same-transaction claim for all events.

conversation.reply_sent is emitted only after outbound delivery is confirmed as sent, so it cannot be recorded in the same transaction as the original conversation change. Describe transactional recording only where it applies, or state that event timing varies by event type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/modules/README.md` at line 31, Update the “Typed events” description in
the module README to remove the blanket claim that all eight domain events are
recorded in the same transaction as their triggering changes. State that
transactional recording or event timing varies by event type, while preserving
the existing event examples and webhook link.

| **Webhook delivery** | Registered HTTPS endpoints receive signed, at-least-once notifications of those events. | [webhooks.md](./webhooks.md) |
| **Assistant actors** | AI principals that authenticate with a bearer token, read conversations through the same read API Agents use, and post draft replies that a human Agent must approve before anything is sent. | [assistants-and-drafts.md](./assistants-and-drafts.md) |

A module typically uses all three: it hears about inbound mail via a
webhook, reads the full conversation via the API, and posts a draft back as
an Assistant. That is exactly the shape of the first real module,
`module-draft-assistant`, referenced throughout these docs as a worked
example.

## Where the substrate lives on the wire

Every route below sits under `/api/v1` on your Helpthread deployment's base
URL (e.g. `https://your-helpdesk.example.com`). There is no separate "module
API" host — it is the same Agent Inbox API a human Agent's browser talks to,
with two additional credential classes layered on top of the original
service-token model.

### Who calls what, authenticated how

| Caller | Credential | Used for |
|---|---|---|
| **Operator / admin tooling** | `Authorization: Bearer <HELPTHREAD_API_TOKEN>` (the deployment's one service token) **plus** `X-Helpthread-Agent-Id: <admin Agent's uuid>` | Registering webhooks, creating/rotating Assistants, approving or discarding drafts — anything an admin Agent does from a script instead of the UI. |
| **A module, at runtime** | `Authorization: Bearer ht_asst_<id>_<secret>` (the Assistant's own token) | Reading conversations, posting drafts, posting notes — nothing else (see [assistants-and-drafts.md](./assistants-and-drafts.md)'s fixed capability set). |
| **A module's webhook receiver** | No inbound credential — instead it *verifies* the `X-Helpthread-Signature` header on every delivery it receives (see [webhooks.md](./webhooks.md)). | Confirming a delivery genuinely came from your Helpthread deployment. |

Every non-2xx response from this API, on every route, uses the same JSON
error envelope:

```json
{ "error": { "code": "validation_failed", "message": "..." } }
```

and every response — success or error — is sent with `Cache-Control:
no-store` (this is authenticated support data; it is never safe to cache).

## Non-goals for v1 (deliberately not built yet)

Carried over honestly from the spec, because a module author should not go
looking for these:

- No in-process/build-time module API — modules are out-of-process only.
- No UI injection points.
- No general scopes/permissions system — an Assistant's capability set is a
small fixed list (see [assistants-and-drafts.md](./assistants-and-drafts.md)),
not something you configure.
- No marketplace plumbing — license keys, a module registry, usage metering.
- No webhook redelivery tooling beyond the one-off `POST .../test` ping.

Each of these waits for a real module that needs it.

## Guides

- **[webhooks.md](./webhooks.md)** — registering an endpoint, the event
vocabulary and envelope, verifying `X-Helpthread-Signature` (complete,
runnable TypeScript sample), delivery guarantees, auto-disable and health
visibility.
- **[assistants-and-drafts.md](./assistants-and-drafts.md)** — creating an
Assistant and handling its token, the fixed capability set, posting a
draft, and the human Agent approval flow.
233 changes: 233 additions & 0 deletions docs/modules/assistants-and-drafts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
# Assistants: identity, capabilities, drafts, and approval

An **Assistant** is an AI actor principal — never a human (that's an
**Agent**; see [README.md](./README.md)'s vocabulary section). A module that
wants to read conversations and propose replies authenticates as an
Assistant, using a bearer token an admin Agent mints for it. An Assistant
can never send mail directly: every customer-facing reply it writes is a
draft, and a human Agent must approve it before anything reaches the
customer. An internal note is the one exception — `POST
/api/v1/conversations/{id}/notes` (below) is a direct write, visible to
Agents immediately, with no draft/approval step, because a note never
reaches the customer in the first place.

Examples below use the same `$BASE_URL`, `$HELPTHREAD_API_TOKEN`, and
`$ADMIN_AGENT_ID` as [webhooks.md](./webhooks.md) for the **admin**
endpoints (creating/managing Assistants, approving/discarding drafts — all
Agent actions). The Assistant's own token, once minted, is a completely
separate credential used only by the module itself.

## Creating an assistant

```sh
curl -X POST "$BASE_URL/api/v1/assistants" \
-H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \
-H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" \
-H "Content-Type: application/json" \
-d '{"name": "Draft Assistant", "module": "your-module-slug"}'
```

Both `name` (1–200 characters) and `module` (1–100 characters, free text —
nothing validates it against a registry) are required; either missing or
out of range is `400 validation_failed`.

Response, `201`:

```json
{
"assistant": {
"id": "c7a1...-uuid",
"name": "Draft Assistant",
"module": "your-module-slug",
"status": "active",
"createdByAgentId": "<admin Agent's uuid>",
"createdAt": "2026-07-19T00:00:00.000Z",
"updatedAt": "2026-07-19T00:00:00.000Z"
},
"token": "ht_asst_c7a1...-uuid_<secret>"
}
```

## Token handling

**`token` is shown exactly once, in the create (or rotate) response.** Only
a SHA-256 digest of its secret half is ever persisted — there is no "reveal
token" endpoint and no way to recover a lost one. Copy it immediately into
wherever your module reads its configuration (an environment variable is
the normal choice) and treat it like any other high-entropy secret: never
commit it, never log it.

The token has the shape `ht_asst_<assistantId>_<secret>`. Use it as-is on
every Assistant-authenticated request:

```sh
curl "$BASE_URL/api/v1/conversations/$CONVERSATION_ID" \
-H "Authorization: Bearer $ASSISTANT_TOKEN"
```

Note there is **no** `X-Helpthread-Agent-Id` header on Assistant-
authenticated calls — the token itself carries the Assistant's identity;
that header is only for Agent-authenticated calls (creating/managing
Assistants, approving/discarding drafts — see below).

**Rotation** mints a fresh secret for the *same* assistant id (so any
`author_assistant_id` a past draft already recorded stays valid) and
returns the new token once; the old one stops verifying immediately —
there is no overlap window:

```sh
curl -X POST "$BASE_URL/api/v1/assistants/$ASSISTANT_ID/rotate-token" \
-H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \
-H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID"
```

Also available, both admin-only: `GET /api/v1/assistants` (roster, never
includes any token) and `PATCH /api/v1/assistants/{id}` with `{"name":
...}` and/or `{"status": "active" | "disabled"}` — a `disabled` Assistant's
token stops authenticating immediately, without needing rotation.

## The fixed capability set

An Assistant's token authenticates it, but that alone doesn't authorize
every route — there is exactly one capability-enforcement point
(`src/api/index.ts`), and it allows an Assistant through to only:

- `GET /api/v1/conversations` and `GET /api/v1/conversations/{id}` — the
same read surface an Agent's UI uses, so a module can pull full thread
content once a webhook tells it something changed.
- `POST /api/v1/conversations/{id}/drafts` — propose a reply (below).
- `POST /api/v1/conversations/{id}/notes` — leave an internal note.

Every other route — including anything under `/api/v1/webhooks`,
`/api/v1/assistants`, sending a reply, changing status/tags/assignee, or
approving/discarding a draft — answers `403 forbidden` to an Assistant
caller, even though the token itself is valid. There is no scopes system to
configure this differently; a wider capability set waits for a real module
that needs one.

Soft-deleted conversations are invisible to an Assistant exactly as they
are to everyone else: a `404`, indistinguishable from never having existed.

## Posting a draft

```sh
curl -X POST "$BASE_URL/api/v1/conversations/$CONVERSATION_ID/drafts" \
-H "Authorization: Bearer $ASSISTANT_TOKEN" \
-H "Idempotency-Key: $EVENT_ID" \
-H "Content-Type: application/json" \
-d '{"bodyText": "Thanks for reaching out — here is how to reset your password..."}'
```

- `bodyText` is required, 1–5000 characters. `bodyHtml` is optional (no
length bound, but must be a string if present).
- `Idempotency-Key` is **required** — use the triggering webhook delivery's
`eventId` (the pattern this guide recommends throughout): if the same
`conversation.message_received` delivery is retried, replaying the same
`Idempotency-Key` against the same conversation returns the original
draft instead of creating a second one. This is enforced server-side, not
just a convention — draft creation is idempotent by construction.

Response, `201`, a `ThreadView` (the same shape the conversation-detail
endpoint returns for any thread):

```json
{
"id": "d4e2...-uuid",
"direction": "outbound",
"from": "support@your-helpdesk.example.com",
"bodyText": "Thanks for reaching out — here is how to reset your password...",
"bodyHtml": null,
"deliveryStatus": null,
"customerViewedAt": null,
"attachments": [],
"createdAt": "2026-07-19T00:00:05.000Z",
"authorKind": "assistant",
"draftStatus": "awaiting_review"
}
```

Note `deliveryStatus: null` — a draft is inert until an Agent approves it;
nothing about posting a draft can cause mail to leave the system. Posting a
draft fires a `draft.created` event ([webhooks.md](./webhooks.md)'s
vocabulary) with `{ threadId, assistantId }`. An unresolved draft also does
**not** reopen a closed conversation or bump its activity timestamp — a
draft sitting in the review queue is not, by itself, evidence that a human
looked at anything.

## The Agent approval flow

Everything past this point is an **Agent** action — the core Helpthread
inbox UI does this for a human clicking "approve" or "discard," and it
consumes exactly the same API, so these are also the calls a module author
would use to build their own review tooling or to understand what the UI is
doing. All three require `Authorization: Bearer $HELPTHREAD_API_TOKEN` +
`X-Helpthread-Agent-Id: <acting Agent's uuid>` — missing either is `401`.

**List the review queue** (every conversation's drafts, across the whole
deployment, newest first):

```sh
curl "$BASE_URL/api/v1/drafts?status=awaiting_review" \
-H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \
-H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID"
```

`status=awaiting_review` is required and is the only legal value — resolved
drafts show up in their conversation's own detail view, not here. Supports
`limit` (default 25, max 50) and keyset `cursor` pagination via the
returned `nextCursor`.

**Approve**, optionally editing the body first:

```sh
# Approve as-written
curl -X POST "$BASE_URL/api/v1/drafts/$THREAD_ID/approve" \
-H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \
-H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID"

# Approve with edits (recorded as draftEdited: true)
curl -X POST "$BASE_URL/api/v1/drafts/$THREAD_ID/approve" \
-H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \
-H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" \
-H "Content-Type: application/json" \
-d '{"bodyText": "Edited reply text..."}'
```

Approval is a state transition, not a resend: it mints the reply's
threading token and Message-ID, derives the envelope (recipient, subject,
`In-Reply-To`/`References`) exactly the way a normal Agent reply does, and
hands off to the same delivery worker — the mail that goes out is
equivalent to what a human typing the same body and hitting reply would
send. Fires `draft.resolved` immediately (`{ threadId, resolution:
'approved', edited }`), and `conversation.reply_sent` once delivery actually
confirms `sent` (not at accept-for-send time — modules reacting to "we
replied" get truth, not intent).

Refused `404` (indistinguishable-from-nonexistent) if the conversation is
missing/soft-deleted or `$THREAD_ID` doesn't name a draft currently
`awaiting_review`; refused `409 conflict` if the conversation is `spam`.

**Discard** (no send, row kept for audit):

```sh
curl -X POST "$BASE_URL/api/v1/drafts/$THREAD_ID/discard" \
-H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \
-H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID"
```

Sets `draftStatus: 'discarded'` and fires `draft.resolved` (`{ threadId,
resolution: 'discarded', edited: false }`). No `spam` restriction — discarding
a draft on a spam conversation is harmless, unlike approving one.

Both approve and discard return the updated `ThreadView` on success, the
same shape draft-creation returns above.

## Invariants worth knowing, test-asserted in the engine

- An Assistant call can never, by itself, cause outbound mail — the only
path to a sent message is an Agent's explicit approval.
- A draft never leaves the system without an approving Agent's identity
recorded on the row (`approved_by_agent_id`).
- An unresolved or discarded draft is excluded from a conversation's
preview text and thread count — it isn't conversation content until it
sends.
Loading
Loading