Event System
The event bus is the sole mechanism for inter-app communication. Apps never query each other's databases. Instead, they emit events when things happen, and other apps listen and react.
The event bus is powered by Amebo, an HTTP event broker. Amebo handles action registration, schema validation, webhook delivery, retries, and deduplication.
Setting up Amebo (local development)
1. Start Amebo
If you run Amebo standalone (recommended for dev):
cd ~/Documents/opensource/amebo
amebo # uses examples/amebo.json as config
Or via Docker Compose (starts on port 8800):
docker compose up amebo
2. Configure your .env
AMEBO_URL=http://localhost:3310
AMEBO_SECRET=your-local-dev-secret-here
AMEBO_SECRET is the HMAC shared secret used to sign all requests between Saaslivery and Amebo. It must be at least 16 characters.
3. Register the application and set the secret
python remote.py amebo setup --env .env
This does three things:
- Gets an admin token from Amebo
- Registers the
microcessorapplication (or regenerates the API key if it already exists) - Sets the HMAC secret so it matches your
.env
You only need to run this once. After that, starting the Saaslivery server will automatically register all actions and subscriptions.
4. Start the server
python app.py
On startup you should see actions registering and subscriptions binding:
[events] registered action: system.app.installed
[events] registered action: system.workspace.member.added
...
[events] registered action: core.chat.message.sent
[events] subscribed chat → core.chat.message.sent
[events] subscribed chat → system.workspace.member.added
[events] ready — 47 emittable actions
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401 Unauthorized on action registration |
HMAC secret mismatch | Run python remote.py amebo setup --env .env |
AMEBO_URL or AMEBO_SECRET not set — skipping event bus |
Missing .env vars | Add AMEBO_URL and AMEBO_SECRET to .env |
403 Forbidden on subscription |
The action doesn't exist in Amebo yet | Make sure the emitting app's events.yml declares the action |
Event naming
{scope}.{app}.{resource}.{action}
| Segment | Description | Examples |
|---|---|---|
| scope | Origin type | core, ext, system |
| app | App identifier | chat, tasks, acme.invoices |
| resource | Entity being acted on | message, task, member |
| action | Past-tense verb | created, updated, deleted, sent |
Scopes
| Scope | Who can emit | Example |
|---|---|---|
core.* |
Saaslivery-maintained apps only | core.tasks.task.created |
ext.{publisher}.{app}.* |
Third-party apps (own namespace only) | ext.acme.invoices.invoice.paid |
system.* |
Platform runtime only | system.workspace.member.added |
How events flow
Handler calls ctx_emit()
│
▼
ctx stages the event payload
│
▼
Handler returns 2xx response
│
▼
AFTER hook (emit_staged_events) fires
│
▼
EventBus.emit() → POST /v1/events to Amebo
│
▼
Amebo validates payload against action schema
│
▼
Amebo creates gists (one per subscriber)
│
▼
Aproko daemon delivers webhooks to subscribers
│
▼
POST {app.address}{handler} with signed payload
│
▼
receive_event() verifies signature and dispatches
Declaring events: events.yml
Every app declares its events in events.yml at the app root. This file has two sections:
- emits — actions this app publishes, with JSON schemas for payload validation
- scans — actions this app subscribes to, with webhook handler paths
# apps/bookmarks/events.yml
emits:
- action: core.bookmarks.bookmark.created
description: A bookmark was saved
schema:
type: object
properties:
bookmark_id: {type: string}
url: {type: string}
title: {type: string}
created_by: {type: string}
required: [bookmark_id, url, title, created_by]
- action: core.bookmarks.bookmark.deleted
description: A bookmark was removed
schema:
type: object
properties:
bookmark_id: {type: string}
deleted_by: {type: string}
required: [bookmark_id, deleted_by]
scans:
- action: core.tasks.task.created
handler: /events/tasks-task-created
max_retries: 3
At startup, the platform auto-discovers every events.yml, registers all actions in Amebo, and creates all subscriptions. No manual registration needed.
System events
Platform-level system.* events are declared in core/events.yml and registered before any app events. Apps can subscribe to them but never emit them.
Handler path convention
Webhook handler paths follow the pattern: /events/{action-with-dots-as-dashes}
core.chat.message.sent → /events/chat-message-sent
system.workspace.member.added → /events/workspace-member-added
This is a convention, not enforced. The actual URL is whatever you declare in scans[].handler.
Emitting events
Use ctx_emit() to stage an event for emission. Events are only sent to Amebo after the handler returns a 2xx response.
from core.events import ctx_emit
async def create_bookmark(req: Request, res: Response, ctx: Context):
# ... create the bookmark ...
ctx_emit(ctx, "core.bookmarks.bookmark.created",
bookmark_id=new_id,
url=data["url"],
title=data["title"],
created_by=member_mrn,
)
return res.out(HTTPStatus.CREATED, one(bookmark))
How it works:
ctx_emit()stores the payload onctxkeyed by the action name- The
emit_staged_eventsAFTER hook scansctxfor action-shaped keys (core.*,ext.*,system.*) - If the response was 2xx, it calls
EventBus.emit()for each staged event workspace_idis injected into event metadata automatically fromctx.workspace_idNonevalues are stripped from the payload so Amebo schema validation doesn't reject unset fields
Events are fire-and-forget — if Amebo is down, the event is silently dropped. This is by design: the handler already succeeded, and the event will be retried by Amebo's delivery daemon if the subscriber didn't receive it.
Listening to events
1. Declare the subscription in events.yml
scans:
- action: core.tasks.task.assigned
handler: /events/tasks-task-assigned
max_retries: 3
2. Register the webhook route in plugin.py
# plugin.py
api = app.subdomain("api")
api.POST("/events/tasks-task-assigned", "apps.myapp.events.on_task_assigned")
3. Write the handler
Webhook handlers receive the event via the standard receive_event() dispatcher in core/events.py. The platform verifies the HMAC signature before your code runs.
# events.py
async def on_task_assigned(payload: dict, workspace_id: str, app):
task_id = payload["task_id"]
assignee_mrn = payload["assignee_mrn"]
# ... react to the event ...
Register the handler in your plugin's startup:
# plugin.py
from apps.myapp.events import on_task_assigned
def register_event_handlers(app):
handlers = app.peek("_event_handlers") or {}
handlers["core.tasks.task.assigned"] = on_task_assigned
app.keep("_event_handlers", handlers)
Webhook delivery
Amebo delivers events as signed HTTP POST requests:
POST {subscriber.address}{handler}
Content-Type: application/json
x-amebo-signature: <hmac-sha256 of body>
x-amebo-event-id: <gist-uuid>
x-amebo-delivery-attempt: <retry-count>
{"action": "core.tasks.task.assigned", "metadata": {...}, "payload": {...}}
The platform's receive_event() handler verifies the signature, extracts workspace_id from metadata, and dispatches to your registered handler.
Delivery guarantees
| Guarantee | Detail |
|---|---|
| At-least-once | Handlers must be idempotent — you may receive the same event more than once |
| Async | Events are sent after the originating handler returns, not inline |
| Retry on failure | Failed deliveries retry up to max_retries (default 3) |
| Deduplication | Each event has a deduper key — same deduper + same payload is rejected |
| Delayed delivery | Use sleep_until for scheduled events (e.g., call reminders) |
Payload vs metadata
Events carry two data fields:
- payload — required, validated against the action's JSON schema, subject to redaction rules
- metadata — optional, free-form, never validated or redacted
The platform automatically injects workspace_id into metadata. Use metadata for envelope-level concerns: correlation IDs, source timestamps, trace context.
Core event catalog
Account
| Event | Payload |
|---|---|
core.account.application.installed |
app_slug, app_name, installed_by |
core.account.application.uninstalled |
app_slug, app_name, uninstalled_by |
core.account.role.created |
role_id, role_name, created_by |
core.account.role.updated |
role_id, role_name, changed_fields, updated_by |
core.account.role.deleted |
role_id, role_name, deleted_by, affected_members |
core.account.member.invited |
email, invited_by, platform_role |
core.account.member.removed |
member_id, removed_by |
core.account.member.role_changed |
member_id, from_role, to_role, changed_by |
core.account.member.avatar_updated |
member_id, avatar_url |
Chat
| Event | Payload |
|---|---|
core.chat.message.sent |
chat_id, message_id, sender_mrn, content_preview, has_media |
core.chat.message.edited |
chat_id, message_id, sender_mrn |
core.chat.message.deleted |
chat_id, message_id, sender_mrn |
core.chat.chat.created |
chat_id, type, name, creator_mrn, participant_mrns |
core.chat.chat.updated |
chat_id, changes, updated_by |
core.chat.participant.added |
chat_id, member_mrn, added_by_mrn |
core.chat.participant.removed |
chat_id, member_mrn, removed_by_mrn |
core.member.mentioned |
source, mentioned_mrn, mentioner_mrn, resource_mrn, link, preview |
core.chat.file.shared |
chat_id, message_id, sender_mrn, file_key, filename, content_type, size_bytes |
core.chat.call.requested |
chat_id, chat_name, participant_mrns, mode, requested_by |
Calls
| Event | Payload |
|---|---|
core.calls.call.created |
call_id, type, mode, title, creator_mrn, source_mrn, scheduled_at |
core.calls.call.started |
call_id, join_url, title, mode, source_mrn, participant_mrns, creator_mrn |
core.calls.call.ended |
call_id, source_mrn, duration_seconds, participant_count |
core.calls.call.scheduled |
call_id, title, scheduled_at, participant_mrns, join_url |
core.calls.call.reminder |
call_id, title, join_url, participant_mrns, scheduled_at |
core.calls.participant.joined |
call_id, member_mrn |
core.calls.participant.left |
call_id, member_mrn |
core.calls.recording.ready |
call_id, recording_id, storage_key, duration_seconds, size_bytes |
Tasks
| Event | Payload |
|---|---|
core.tasks.task.created |
task_id, board_id, title, creator_mrn |
core.tasks.task.updated |
task_id, changed_fields |
core.tasks.task.assigned |
task_id, assignee_mrn, assigned_by |
core.tasks.task.unassigned |
task_id, assignee_mrn |
core.tasks.task.completed |
task_id, completed_by |
core.tasks.task.deleted |
task_id, deleted_by |
core.tasks.task.bulk_deleted |
task_ids, deleted_by |
core.tasks.task.moved |
task_id, from_board, to_board |
core.tasks.task.commented |
task_id, comment_id, commenter_mrn |
core.tasks.task.pulled |
task_id, pulled_by |
core.tasks.task.returned |
task_id, returned_by |
core.tasks.board.created |
board_id, name, creator_mrn |
core.tasks.board.deleted |
board_id, deleted_by |
core.tasks.board.archived |
board_id |
core.tasks.board.cleared |
board_id |
core.tasks.board.topic_renamed |
board_id, old_name, new_name |
core.tasks.schedule.created |
schedule_id |
core.tasks.schedule.updated |
schedule_id |
core.tasks.schedule.completed |
schedule_id |
core.tasks.deliverable.delivered |
deliverable_id |
System
| Event | Payload |
|---|---|
system.app.installed |
app_slug, installed_by |
system.app.uninstalled |
app_slug, uninstalled_by |
system.workspace.created |
name, owner_id |
system.workspace.member.added |
member_id, role |
system.workspace.member.removed |
member_id, removed_by |
system.security.role.created |
role_id, role_name, created_by |
system.security.permission.denied |
member_id, permission, app_slug, route |