Events & real time

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:

  1. Gets an admin token from Amebo
  2. Registers the microcessor application (or regenerates the API key if it already exists)
  3. 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:

  1. ctx_emit() stores the payload on ctx keyed by the action name
  2. The emit_staged_events AFTER hook scans ctx for action-shaped keys (core.*, ext.*, system.*)
  3. If the response was 2xx, it calls EventBus.emit() for each staged event
  4. workspace_id is injected into event metadata automatically from ctx.workspace_id
  5. None values 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