Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
212d340
feat(bots): add durable outbound webhooks
hmans Sep 5, 2026
ce7e39c
fix(bots): fence webhook replacement results and access loss
hmans Sep 5, 2026
87ab865
fix(i18n): complete outbound webhook translations
hmans Sep 5, 2026
ec8a60f
test(bots): cover outbound webhook page integration
hmans Sep 5, 2026
ae3f8c9
fix(bots): stop webhook status reads during deletion
hmans Sep 5, 2026
a1ecb3d
refactor(bots): use JetStream webhook jobs without KV bookkeeping
hmans Sep 5, 2026
63445a3
feat(jobs): share a retained JetStream work queue
hmans Sep 5, 2026
086f279
style(proto): remove trailing empty line from webhook job schema
hmans Sep 5, 2026
891478b
docs(webhooks): align failure and deduplication contracts
hmans Sep 5, 2026
3f3fa65
feat(admin): show shared job queue statistics in System
hmans Sep 5, 2026
2353b07
refactor(jobs): move webhook delivery protobuf into jobs package
hmans Sep 5, 2026
0cddf25
refactor(bots): replace durable jobs with best-effort webhook delivery
hmans Sep 6, 2026
303c6f4
feat(bots): replace test bot with Runling webhook workflow
hmans Sep 7, 2026
bcd95d6
feat(bots): support independent outbound webhook credentials
hmans Sep 8, 2026
4ea2168
fix(i18n): complete outbound webhook lifecycle translations
hmans Sep 8, 2026
0f275ce
perf(bots): share endpoint projection checks across source bursts
hmans Sep 8, 2026
5b2716b
feat(bots): retain webhook failures in a typed operational log
hmans Sep 8, 2026
ab10a76
test(log): cover expiry recreation and backup boundaries
hmans Sep 8, 2026
b7986da
fix(examples): support released and linked Runling task APIs
hmans Sep 8, 2026
e27aa1d
refactor(config): group webhook and operational log settings under core
hmans Sep 8, 2026
f22a4db
fix(bots): align outbound webhook creation with credential dialogs
hmans Sep 8, 2026
9f6be07
docs(bots): correct webhook setup and delivery guarantees
hmans Sep 8, 2026
0bd36af
feat(bots): edit outbound destinations and streamline integration man…
hmans Sep 8, 2026
d26ca5d
docs(bots): describe the current Runling agent workflow
hmans Sep 8, 2026
d5b40d7
refactor(example): use the published Runling workflow API
hmans Sep 8, 2026
824cb15
docs(architecture): record outbound endpoint edit semantics
hmans Sep 8, 2026
065552b
fix(i18n): complete outbound header hints in all locales
hmans Sep 8, 2026
e967160
test(bots): locate icon actions by accessible name
hmans Sep 8, 2026
5ede828
docs(ui): explain bot integrations and group webhook sections
hmans Sep 8, 2026
bc2b217
refactor(bots): simplify webhook events and failure records
hmans Sep 8, 2026
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
4 changes: 4 additions & 0 deletions .conductor/settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ url = "https://mailpit.$CONDUCTOR_WORKSPACE_NAME.localhost:42444"
name = "LiveKit"
url = "https://livekit.$CONDUCTOR_WORKSPACE_NAME.localhost:42444"

[[preview_urls]]
name = "Runling"
url = "https://runling.$CONDUCTOR_WORKSPACE_NAME.localhost:42444"

[[preview_urls]]
name = "Storybook"
url = "https://storybook.$CONDUCTOR_WORKSPACE_NAME.localhost:42444"
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -482,11 +482,11 @@ jobs:
uses: ./.github/actions/setup-playwright
timeout-minutes: 15

- name: Build E2E server and test bot
- name: Build E2E server and test Runling bot
if: needs.changes.outputs.chatto == 'true'
run: |
mise build-e2e-server
mise test-bot-build
mise test-runling-bot

- name: Run E2E tests
if: needs.changes.outputs.chatto == 'true'
Expand Down
5 changes: 3 additions & 2 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ Frontend, Examples, and Documentation Components:
- markdown-it (https://github.com/markdown-it/markdown-it) - MIT License
- node-semver (https://github.com/npm/node-semver) - ISC License
- Parcel watcher (https://github.com/parcel-bundler/watcher) - MIT License
- Pi coding agent, agent core, AI SDK, and server support (https://github.com/earendil-works/pi) - MIT License
- Undici, used by TestBot for address-pinned web requests (https://github.com/nodejs/undici) - MIT License
- Undici, used by the Runling bot for address-pinned web requests (https://github.com/nodejs/undici) - MIT License
- Runling workflow runner (https://github.com/chattocorp/runling) - MIT License
- Pi coding agent, AI SDK, and terminal UI, used by Runling (https://github.com/earendil-works/pi) - MIT License
- Playwright (https://playwright.dev/) - Apache License 2.0
- Prettier and plugins (https://prettier.io/) - MIT License
- Sharp image toolkit (https://sharp.pixelplumbing.com/) - Apache License 2.0
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ services. In Conductor, replace `<workspace>` with the workspace name:
- Authling: `https://authling.<workspace>.localhost:42444`
- Mailpit: `https://mailpit.<workspace>.localhost:42444`
- LiveKit: `https://livekit.<workspace>.localhost:42444`
- Runling: `https://runling.<workspace>.localhost:42444`

Outside Conductor, Portless uses the `local` route suffix. Services listen on
loopback ports from base port `4000` (or `$CONDUCTOR_PORT` in Conductor).
Expand All @@ -57,6 +58,12 @@ Create an Authling account, read its verification code in Mailpit, then choose
login. The stack also creates Chatto owner `alice` and member `bob`; both use
the development-only password `foobar123`.

The stack starts the [Runling bot example](examples/runling-bot/README.md)
on loopback at the base port plus three (`http://localhost:4003` outside
Conductor). It uses the bootstrap TestBot account and receives the backend URL
and API key path automatically. On an empty server, bootstrap also creates
TestBot’s outbound webhook. Existing servers keep their saved configuration.

Chatto uses Authling as its development OIDC provider. Chatto stores embedded
NATS data in `cli/data/nats/` and search data in `cli/data/search/`. Authling
identity data is in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,131 @@ human accounts, with the bot's user ID as the target. See the
[BotService reference](/reference/connectrpc-api/bots/),
[UserService reference](/reference/connectrpc-api/users/), and
[AdminPermissionService reference](/reference/connectrpc-api/admin-permissions/).

## Outbound webhooks

Open a bot in **Server Admin → Bots**, then select **Create webhook** in
**Outbound webhooks**. Enter a name, the destination URL and, if required, the
complete Authorization header value. Select **Create webhook** to save it.
New webhooks start enabled. Use **Pause** to stop deliveries and **Resume** to
start them again.

Each endpoint has its own signing secret, shown once after creation. If your
tool verifies signatures, copy the secret before closing the dialog. You can
skip this step if your receiver does not verify signatures.

Each bot can have up to 20 endpoints, including paused endpoints. Each enabled
endpoint receives new messages that directly mention the
bot and new messages in DMs that include the bot. This includes thread replies.
A DM that mentions the bot produces one delivery with both triggers. Messages
from the bot itself, ordinary channel messages, edits, and reactions do not
activate the webhook. Notification preferences do not affect it.

Chatto shows the saved names and URLs only to bot managers. Authorization values
and signing secrets are not returned by later reads. Select **Edit webhook**
(the pencil icon) to change the destination URL. You can keep, replace, or
remove the Authorization header. Edits preserve the signing secret and creation
time, and cancel queued retries for the previous settings. To change the name
or signing secret, create a new endpoint and revoke the old one.

Select **Pause** to stop new deliveries and cancel queued retries. **Resume**
accepts new messages; it does not send messages from the paused period or revive
cancelled retries. Pausing preserves the credentials. **Revoke webhook** removes
one endpoint permanently. An HTTP request already in flight can still finish.
These actions do not affect other endpoints on the bot.

### Request body

Every request has this structure:

```json
{
"version": 1,
"id": "stable-delivery-id",
"type": "message.created",
"triggers": ["direct_message", "mention"],
"occurred_at": "2026-09-05T12:00:00Z",
"bot_id": "bot-id",
"room_id": "room-id",
"thread_root_id": null,
"message": {
"id": "message-id",
"author_id": "author-id",
"body": "Hello @helper_bot"
}
}
```

Use `triggers` to route mentions and DMs in your tool. `thread_root_id` is null
for a root message. Attachments and other event types are not included in this
version. Your tool must accept this JSON structure; Chatto does not translate
it to a tool-specific payload.

Return an HTTP 2xx response after you accept the request. Process longer tasks
separately and use the bot API key to reply through the normal API. Chatto does
not interpret the response body.

### Signatures and authentication

Chatto adds these headers:

- `Chatto-Webhook-Id`: the delivery ID from the JSON body.
- `Chatto-Webhook-Timestamp`: Unix time in seconds for this attempt.
- `Chatto-Webhook-Signature`: `v1=` followed by a hexadecimal HMAC-SHA256 value.

To verify the signature, use the signing secret **as its displayed UTF-8 text**
without Base64 decoding. Compute HMAC-SHA256 over the timestamp, a full stop,
and the exact request-body bytes. Compare signatures in constant time. Reject
an old timestamp, for example after five minutes, and deduplicate the delivery
ID. A retry has the same ID and a new timestamp.

A generic tool can authenticate through its own secret URL or the configured
Authorization header. It can ignore the signature headers if it does not
support verification. Keep URL credentials, header values, and the signing
secret private.

### Retries, expiry, and access

Chatto retries transport failures and all non-2xx responses. It never follows
redirects. The default policy permits five attempts within 24 hours of the
source message. The delay starts at 30 seconds and doubles up to 30 minutes.
Each request has a ten-second timeout and cannot run beyond delivery expiry.

Delivery is best effort. Pending requests and retry timers live in memory.
Restart discards work already accepted by a process, without a failure record.
A lost response or repeated source event can cause a repeat, even if your tool
processed the request. Use the delivery ID to avoid repeating work. Attempts
include failures before HTTP starts. Deliveries can arrive in a different
order. Eight workers per process send and retry requests; failed endpoints
can delay other deliveries while these workers wait.

Chatto checks the bot's current access before sending. It skips delivery when
access is lost, the message is unavailable, or the endpoint is paused or revoked. The
body contains the currently readable message text. An edit can therefore
change the body between attempts. Select **Recent failures** on an endpoint to
read its retained failure history. Records show the time, attempt count, HTTP
status when available, and a safe failure category. History is ordered oldest
first; use **Load more** for the next page.

Failures expire after seven days by default. Operators can set
`core.log.retention` or `CHATTO_CORE_LOG_RETENTION`. Successful deliveries and intentional
skips are not recorded. An empty history does not prove successful delivery.
A later success does not clear a retained failure. Backups exclude this history.

Operators can change retry and expiry policy through
[environment variables or TOML](/reference/environment-variables/#outbound-bot-webhooks).

Destinations require public HTTPS. The names `localhost` and `*.localhost`
permit HTTP and HTTPS, but every resolved address must be loopback. Chatto
checks these addresses when it connects and does not resolve them a second
time. Use a localhost URL with your workspace’s Runling port (workspace port plus
three, such as `http://localhost:55003`). The exception does
not apply to IP literals such as `127.0.0.1` or other private-network hosts.

### Local Runling example

The [Runling example](https://github.com/chattocorp/chatto/tree/main/examples/runling-bot)
receives this payload through Runling, reads the complete thread, and uses an
agent to reply through the Chatto API. The agent can fetch public pages and
consult the Chatto docs. The example includes local setup instructions and tests. The example
uses Runling’s asynchronous run endpoint so Chatto receives an immediate `202`.
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,23 @@ Realtime delivery uses the same room membership, RBAC, projection readiness, and

## Runnable Node Example

Use the repository's [test bot example](https://github.com/chattocorp/chatto/tree/main/examples/test-bot) as a small integration starting point. It uses generated ConnectRPC clients, the protobuf realtime WebSocket, and the Pi SDK. A direct mention in a channel starts a reply in that channel thread. Each human message in a DM starts a reply without a mention. A human must start the DM and include the bot. The example combines short message bursts and permits only one active model call in each conversation. Different conversations can run in parallel. The bot saves its opaque resume cursor only after it handles each frame.

TestBot sends every answer as a thread reply. A root message in a channel or DM
becomes the thread root. Therefore, the bot needs `message.post-in-thread`, but
it does not need `message.post`.

The example gives Pi only a restricted public web-fetch tool and does not log message text, prompts, replies, user names, or credentials. A configured external AI provider receives the text of up to 40 messages from the active channel thread or DM. The example replaces user IDs with prompt-local labels before the model request. Local development uses Pi's no-cost faux provider unless you explicitly configure a real provider and model. With an empty local data directory, `mise dev` creates `test_bot` on the first startup, makes Alice its owner, and starts the Node process. This setup uses development-only bootstrap code. Release builds ignore the bootstrap configuration.
Use the repository's [Runling bot example](https://github.com/chattocorp/chatto/tree/main/examples/runling-bot)
as an integration starting point. It receives outbound webhooks for mentions
and direct messages, then uses Gemini 2.5 Flash Lite through OpenRouter to compose a reply for the
ConnectRPC JSON API. It does not require a realtime WebSocket connection. For channel mentions and DMs,
the workflow loads the complete current thread before composing the answer.
This includes messages without mentions and previous bot replies. The bot can
fetch public web pages and is instructed to consult the Chatto documentation
and cite source pages when answering Chatto questions.

TestBot sends each answer as a thread reply. A root message in a channel or DM
becomes the thread root. The bot needs `message.post-in-thread` and message-read
access. Messages from bots are ignored to prevent reply loops.

`mise dev` starts Runling and supplies its backend URL and API key path.
Follow the example README to set the bot's webhook destination once.
With an empty local data directory, development bootstrap creates `test_bot`
and makes Alice its owner. Release builds ignore the bootstrap configuration.

## Integration Patterns

Expand Down
Loading