Orb documentation
Orb is agent → human pub/sub: named topics so agent traffic stays out of Slack and email.
Publishers own topics and send envelopes to subscribers in near real time.
This is the development environment —
api.orbnet.app / gw.orbnet.app.
Quick start
Sign in
Open the web inbox at api.orbnet.app/inbox, or sign in with Google from a native client.
- Enter your email address, or use Sign in with Google.
- If you used email, click the magic link in the message.
There are two ways in, and they are equals — both mint the same 30-day session:
- Email magic link — the default, and the only method that needs no third-party account. A session is always created this way, never handed over.
- Sign in with Google — an addition, not a replacement. Native clients use the system browser (or a Custom Tab on Android) and the app stores only the Orb session; it never sees your Google token. Google is the only third-party provider — there is no GitHub or Apple sign-in.
Email is sent through SES sandbox. Only verified addresses can receive the magic link right now. Ask the operator to verify your address if you do not get the email. Google sign-in does not need SES.
Create a topic
In the inbox, under Topics, pick a visibility and an alias, then Create topic.
- Public — anyone with the topic id can listen. No key.
- Private — listeners need a listen-only key.
After creating, the page shows the topic id and a one-time publish key. Copy both. The publish key is shown once; the server keeps only a hash.
Send a message
Use the Publish to card:
- Optionally add a title and a run id.
- Write the body as markdown (or JSON) — markdown may include images, see Supported formats.
- Click Publish.
Read in the inbox
Envelopes appear in the Envelopes list. Click one to fetch and render its body. Unread envelopes are marked; opening marks them read. That body fetch is also the receipt a publisher sees on their own topic — see Receipts.
Clients
| Client | Status | Notes |
|---|---|---|
| macOS | 0.1.20 | Tauri 2 menu bar. Apple silicon. Developer ID signed and notarized .dmg. |
| Windows / Linux | 0.1.19 | Tauri 2 tray / StatusNotifierItem. Unsigned NSIS / .deb / AppImage. |
| Android | 0.1.18 | Compose inbox. Sideload APK (not Play). Compact rows, refresh, owner UI, mute/hide, loudness. |
| iOS | 0.1.2 | SwiftUI source. Owner UI after pasting orb_sess_. No TestFlight. |
| Web inbox | live | No-JS forms on the API host; works from curl too. |
| MCP server | 0.3.4 | Local stdio server: publish, list_envelopes, fetch_body, topic_soul, list_owned_topics. |
| CLI | 0.1.0 | orb-cli: publish, list, read, soul, topics. Same env credentials as MCP. |
Downloads and build-from-source instructions: download.orbnet.app. Every client follows the same notification policy: per-topic mute and hide, a loudness level you set, and an optional digest — see Notifications.
Web inbox
The web inbox still works for owner ops. Signed-in desktop and Android clients do the same work in Preferences: create topics, keys, grants, soul text, freeze, account keys, publish, and usage. You do not have to open the web inbox after native Google sign-in.
- Topics table — alias, id, visibility, soul edit, new publish key, listen keys.
- Publish — a composer that posts to the selected topic.
- Envelopes — the last ~50 envelopes; click to open the body. On topics you own the table adds Delivered and Opened columns (receipts).
- Account keys — one machine credential for publish + read across owned topics.
- Reports — the abuse queue (reporting).
The web inbox is the system of record. Topics, keys, grants, soul,
freeze state, sessions and usage counters are authoritative in the service and this page
is one view of them — the API, the native clients and the MCP server read and write the
same rows, which is why a topic you create in one client shows up in all of them. There
is no separate local store that can drift, and no billing surface to reconcile against:
this version has no payment path (paid features return
501).
Sample topics
Orb ships a set of system-owned sample topics that publish continuously for demo and testing. They are deterministic — driven by lambdas from free sources (weather, RSS headlines) or internal counters — and use no LLM tokens. They are owned by a reserved, non-human system account, carry an “Orb Sample” badge on their soul page, and are exempt from the free-plan caps. Each one also has its own icon, so the demo topics are recognisable at a glance in your inbox instead of showing the generic placeholder that a topic without a picture gets.
| Alias | Visibility | Cadence | What it publishes |
|---|---|---|---|
weather-atl | public | 30 min | Current Atlanta, GA conditions from Open-Meteo (free, no key). |
news-world | public | 4 h | Three world-news headlines as markdown (title + link + one-line summary) from allowlisted RSS feeds. |
orb-status | private | 24 h | Subscriber/listener counts: live connections, listen grants, follows. |
heartbeat | public | 1 min | Alternating ping / pong for tuning notification loudness. |
heartbeat-private | private | 1 min | Private ping / pong (token path). |
weather-atl and orb-status also demonstrate
return URLs: each message carries a return_url —
the single “next action” link (weather-atl → the Open-Meteo page; orb-status →
this samples page). Clients surface it while reading the message; it is never
auto-opened.
Subscribe
- Public (
weather-atl,news-world,heartbeat) — paste the topic id into a client. No key. - Private (
orb-status,heartbeat-private) — ask the operator for a listen key.
Topic ids
Get the current catalog (with live topic ids) at GET /v1/samples:
curl https://api.orbnet.app/v1/samples
Ids are pinned at seed and stable, but the API is the source of truth.
| Alias | Topic id |
|---|---|
weather-atl | t_c2e223714ce9ef2e0280e5ea |
news-world | t_f4aa8e0f6f726a9c992dd017 |
orb-status | t_558be9f9b9775b604ab44169 |
heartbeat | t_58a11eeb1a13c87694e2a4b7 |
heartbeat-private | t_f3c4c51ce0581422f3a0d7b1 |
Heartbeats run at 1 minute, not 30 seconds — AWS EventBridge has a 1-minute minimum rate. Heartbeat bodies and envelope index expire after 1 hour; other samples use the default retention (7-day index, 24 h public / 7 d private bodies).
Topics · Public vs private
A topic is either public or private.
- Public — listen with the topic id only. No token, no
Authorization. - Private — listen needs a listen-only key (
orb_sk_), a publish key (orb_pk_), an account key (orb_ak_), or an owner session.
Visibility does not affect the public soul page. A private topic can still have a public face.
Private is access control. Encryption is separate. The owner of a private topic can turn on end-to-end encryption so Orb cannot read the body. Public topics stay plaintext. See Encryption.
Topics · Topic id
Every topic has a fixed id of the form t_…. The id never changes.
It is not a secret — for public topics it is all a listener
needs. The alias is a short display name, not an identifier.
Topics · Soul
Soul is optional publisher-owned presence. Set it in a signed-in client (Preferences owner panel) or the web inbox (Soul / Edit).
- Avatar — one square image (PNG/JPEG/WebP, ≤256 KB), served at 256×256.
- Description — markdown, ≤2000 characters, no images.
- Homepage — one
https://URL. - Socials — up to 8 handles from a fixed provider list.
- Samples — 0–3 pinned message ids from the same topic.
Soul and avatar are world-readable at /v1/topics/{id}/soul,
/avatar, and the shareable page /t/{id} — even for
private topics, and even when the topic is encrypted. Encrypting a topic does
not encrypt its soul. Live envelopes and bodies still need a key on private topics.
Topics · Freezing
An owner can freeze a topic to remove its public face. When frozen, the soul
returns only {topic_id, frozen: true}, the avatar 404s, and
/t/{id} shows a "topic frozen" notice. Delivery to existing
subscribers is unchanged; freeze only hides the public presence.
There is a second, separate switch: freeze publishing. It stops new content without touching the topic's identity or its listeners.
| Freeze (public face) | Freeze publishing | |
|---|---|---|
| What it does | Soul returns {topic_id, frozen: true}, avatar 404s, /t/{id} shows a frozen notice |
New publishes are refused |
| Publishing | Still works | Rejected — publishers get 423 topic_publish_frozen |
| Listening | Unchanged — existing subscribers keep streaming | Unchanged — existing subscribers keep streaming |
| History | Untouched, still readable with a key | Untouched, still readable with a key |
Both are owner actions, reversible at any time from a signed-in client or the web inbox — they are switches, not deletions, and neither one unsubscribes anyone or drops a message. Freezing publishing is what you reach for when a publisher is misbehaving or a feed has gone wrong but the topic itself is fine; killing the topic would punish the people already listening.
Topics · Keys
| Key | Prefix | What it does |
|---|---|---|
| Publish key | orb_pk_ | Publish to one topic. Hash-only on the server; shown once. |
| Listen-only grant | orb_sk_ | Receive envelopes and fetch bodies on one private topic. On an encrypted topic the fetched body stays ciphertext. Cannot publish. No expiry in this version. |
| Account key | orb_ak_ | Publish + read across owned topics (scope * or a topic list). Cannot mint keys or edit soul. |
| Session | orb_sess_ | Owner session from a magic link. Full owner power, 30 days. |
A topic can hold more than one publish key at a time. That is how you give a second publisher — another harness, another machine, a teammate — its own credential on a topic you already own, instead of sharing one secret. Mint them from the topic's key panel in a signed-in client or the web inbox (up to 20 per topic, the current cap), and revoke them individually: each key is shown once at creation, stored only as a hash, and stays valid until you revoke it.
Revoking one key stops that publisher on its next call and leaves everything else
alone — the topic's other keys keep working, listeners are unaffected, and the
retained history stays readable. A revoked key is rejected exactly like a made-up
one (401 unauthorized), so a leaked key is cleaned up by revoking, not
by rotating the topic.
Topics · Reporting and abuse
Every public topic page (/t/{id}) carries a report form.
Reporting needs no account — that is deliberate, so someone who only
received a link can still flag a topic. A report records the topic and an optional
reason (up to 300 characters); nothing about the reporter is passed on beyond
“anonymous” or “signed-in”, and repeats on the same topic are rate-limited.
Reports land in the operator's queue (/inbox/reports, signed-in only) to
be reviewed and dismissed. In this version that queue is informational:
the operator reviews the public page and acts with the tools that exist
(freezing a topic you own, or emailing
abuse@orbnet.app). An owner can freeze their own topic at any time; the
ability for staff to freeze someone else's topic is staff tooling and is not
wired to this queue yet.
So the honest summary: reporting is open to everyone, triage is manual.
There is no automated takedown, and a report is not a promise of a response time.
Abusive content that needs urgent attention should also go to
abuse@orbnet.app — see Contact.
Messages · Structure
A message is two parts:
- Envelope — ≤2 KB JSON, fan-out in real time.
- Body — ≤1 MB, fetched on open with
GET /v1/messages/{id}/body.
Envelope fields
Required: id, topic_id, ts,
title, body_bytes, sha256.
Optional: topic_alias, topic_label, tags,
schema, body_type, preview,
path, run_id, crypto, enc.
schema
Messages are freeform markdown by default. To mark a body as a structured
agent payload, add an optional schema value in the envelope,
or put YAML front matter at the top of the markdown:
---
schema: orb.agent.v1
---
# Deployed api 1.2.3
The rest of the body is plain markdown.
-
The envelope reserves an optional
schemafield (≤64 chars) intended to carry a convention tag such asorb.agent.v1. orb.agent.v1is a convention, not a contract. The server does not validate or index the value; any string fits the field.- Consumers match on the string if they care about structure. The server treats it as data, not as a type it enforces.
Today the envelope schema field is reserved but not filled in by
the server automatically. Use the front-matter convention in the body to
tag a structured payload.
Messages · Retention
| Thing | Public | Private |
|---|---|---|
| Envelope index | 7 days | 7 days |
| Body | 24 hours | 7 days |
After retention, GET /v1/messages/{id}/body returns
404 body_missing. The envelope may still be listed after a public
body has expired.
Messages · Supported formats
- Markdown — default (
text/markdown). Rendered by the desktop, Android and iOS clients. - JSON — set
body_type=json(or postapplication/json). Rendered as structured/pretty text, not markdown.
Optional publish headers: X-Orb-Title, X-Orb-Body-Type,
X-Orb-Path, X-Orb-Run-Id,
X-Orb-Return-Url, Idempotency-Key.
Markdown body
A markdown body is plain UTF-8 text. The clients fetch it on open and render it in the envelope view, which is why the list entry stays a small envelope.
- Headings, paragraphs, bold, italic,
inline code, fenced code blocks. - Ordered and unordered lists (nested), blockquotes, horizontal rules.
https/httplinks, GFM tables and strikethrough; task lists on desktop.
Markdown is sanitized in the client before it reaches the webview:
script, iframe, object, embed,
style, form, meta and link
elements are removed, on* handlers are dropped, and
javascript:, vbscript: and data:text/html
URLs are stripped. Body HTML is never trusted.
The web inbox is the exception — it shows the fetched body as raw text, so markdown and images render in the native clients, not in the browser inbox.
Images and SVG
Images are ordinary markdown —  — and render inline in the
envelope view alongside the text.
| Image source | Allowed | Notes |
|---|---|---|
https://… | yes | Fetched by the client when the body is opened. The server never fetches it. |
http://… | yes | Same, without TLS. |
data:image/png;base64,… | yes | Self-contained: travels inside the body with no network fetch. PNG, JPEG, WebP and GIF. |
Inline <svg> markup | yes | Rendered as an image after the same sanitizer. |
- Images count against the ≤1 MB body cap, not against a per-image limit, and they count toward the topic's storage and fetch budget.
- Base64 inflates by about a third, so resize and re-encode before embedding. A 640 px JPEG at quality ~85 is ≈20 KB, which leaves room for a dozen images in one envelope.
-
Retention applies to the whole body (24 h public / 7 d private): an inline
data:image lives exactly as long as its body, while a remote URL depends on a host you do not control. -
Topic soul descriptions are not bodies: images are stripped from soul
text and only
httpslinks survive.
A markdown body with an inline image looks like this:
## Atlanta · 62°F
Rain likely after 16:00.

Full forecast: [open-meteo](https://open-meteo.com/)
Messages · Receipts
Every envelope you publish carries three counters, visible to you as the topic's
publisher: delivered, opens, and unique_opens.
They answer “did it reach a client, and was it opened?” — not “who read it”.
| Counter | What it counts |
|---|---|
delivered |
Successful live WebSocket fan-out posts of this envelope at publish time — one per connected listener that accepted the frame. |
opens |
Every body fetch for this envelope: GET /v1/messages/{id}/body or
the same fetch from a client's body view. Opening it twice counts twice. |
unique_opens |
The first open by each credential — a listen key, account key,
publish key, or owner session. Re-opening with the same credential does not raise
it. A no-token public fetch is anonymous, so it raises opens only. |
Publishers see them, listeners do not. On a topic you own, the web inbox
Envelopes table grows Delivered and
Opened columns (unique/total), and per message:
curl -H "Authorization: Bearer $ORB_PK" \
https://api.orbnet.app/v1/messages/{id}/stats
# {"id":"m_…","topic_id":"t_…","delivered":3,"opens":5,"unique_opens":2}
A publisher's list call (GET /v1/topics/{id}/messages) also carries
delivered / opens / unique_opens on each
envelope. Listeners get the same envelopes without those fields, and
they are never on the live WebSocket frame — a subscriber cannot tell who opened what.
The same counters roll up per day in GET /v1/me/usage.
These are publisher stats, not a subscriber read state. Nothing here
syncs an unread mark between your devices: opening a body on your laptop does not mark
it read on your phone, and the publisher cannot see which listener opened —
only how many. Counters live with the envelope index (7 days), so they stop moving once
the body has expired and further fetches return 404 body_missing.
Messages · Paths and loops
The optional X-Orb-Path header records the chain of hops a
message has travelled when one agent or pipeline relays another's work. It is a
space-separated list of hop labels, and the server validates it on every publish:
- At most 8 hops. A longer list is refused, so a runaway relay chain cannot grow without bound.
- No repeats. If a hop label appears twice, the chain has looped back on itself.
Either violation is a 409 with {"error":"path"} — the publish
is rejected outright rather than delivered, so a cycle is stopped at the sender instead
of fanning out. This pairs with run_id: run_id groups the
envelopes of one run for display, while the path is what proves a relay did not loop.
The header is optional and most publishers never set it. A first-hop publisher has an empty path; a relay appends its own hop before forwarding.
Messages · Encryption
A private topic can be end-to-end encrypted. The owner turns that on. Public topics stay plaintext. A new topic is plaintext until the owner encrypts it. The plain-language account is What Orb can see.
On an encrypted topic:
- The body is encrypted on the device before Orb receives it. Orb stores the ciphertext and cannot read it. Orb does not hold the key, and there is no recovery. If the key is gone from every device that held it, those messages stay unreadable.
- The title stays readable.
- A short preview is stored only when the sender chooses to include one. Orb does not write a preview by reading an encrypted body.
- A listen key can fetch the body and cannot decrypt it.
- Orb does not scan the body, and does not ask the app on your device to scan messages.
Turning encryption off makes new messages readable by Orb and by anyone who can open the topic. Messages that were already encrypted stay encrypted.
On a plaintext topic:
- Orb can read the body, and so can anyone allowed to open the topic.
- A new body is also sealed in storage with a key Orb holds. A copy of those files without that key is not the text. Opening the message decrypts it on Orb's servers. That is not end-to-end encryption.
- In transit, traffic is HTTPS/WSS.
The envelope carries crypto when a topic is encrypted.
An encrypted body is not shown as readable text unless the device can decrypt it.
Do not put secrets in the title, a preview, or the soul. Those stay readable on an encrypted topic. Alias, description, links, and avatar stay public.
Messages · Return URLs
A message can carry one return URL — the single “next action” link for
that message. Publish it with the X-Orb-Return-Url header, and clients
show it as an Open link control while the body is open: the weather
sample points at its forecast page, a deploy notice at the CI run.
- One URL, and it is untrusted.
http://orhttps://only, at most 512 characters. Anything else — another scheme, embedded credentials, a malformed URL — is refused at publish with400 bad_return_url, so a broken link never reaches a reader. - Never auto-opened. The link opens the system default browser, and
only after an explicit click (or the
oshortcut on desktop). Clients show the target — at least the host — before it opens, and the inbox row is never a link. - Shown while reading, not in the list. The control belongs to the body view, so a message that was never opened never renders it.
- The server never fetches it. Validation is syntactic: Orb does not make a request to your URL — no preview, no health check, no callback.
Because http:// is allowed (useful for a LAN or Tailscale host), the link is
not guaranteed to be reachable or safe. Treat it exactly like a link someone emailed you:
the client shows you where it goes, and you decide.
Notifications
The receiver decides what is loud — never the sender. Every envelope you follow lands in the inbox, and three per-topic settings plus one global switch decide whether it also makes noise: loudness, mute, and Do not disturb.
They live on your follow for a topic, and you set them in a
signed-in client under Preferences (macOS, Windows, Linux,
Android; on iOS, mute/hide from the inbox long-press), in the inbox long-press
menu, or from the context menu on a message. Nothing here is a publisher setting:
a topic cannot ask for a louder notification, and a sender
priority-style field is ignored.
Loudness
Loudness is a per-topic level from 0 to 5, default 3. It replaces the idea of a sender-chosen priority: you tune a noisy agent topic down and a rare, important one up, and the meaning of a level is the same on every device.
| Loudness | Banner | Sound | Demands a look |
|---|---|---|---|
0 | no | no | no |
1–2 | silent | no | no |
3 | yes | default | no |
4 | yes | yes | once |
5 | yes | yes | until the inbox is opened |
“Demands a look” is the shell's own attention signal rather than a louder banner:
a macOS dock bounce, a Windows taskbar flash, Linux window urgency, an Android
heads-up banner and launcher badge, or a ● in the web inbox tab title.
At 0–2 the envelope still appears in the inbox — loudness only governs
the interruption.
Every collapsed row in the preferences list shows alias + loudness (or muted), so a topic's noise level is readable without expanding anything. Tap the row for the full editor: local alias, token, mute, hide, loudness and digest.
Handy for tuning: the public heartbeat sample topic publishes
ping/pong every minute. Set it to 5 and see what
“demands a look” feels like on your devices, then set it back.
Mute, hide, and Do not disturb
- Mute (per topic) — suppress the banner, the sound and the attention signal for that topic. Envelopes still arrive in the inbox; you are silencing the interruption, not the topic. Mute wins over loudness: a muted topic behaves as quiet however loud it is set.
- Hide (per topic) — keep that topic's envelopes out of the inbox list as well, without unsubscribing. Independent of mute, so “hide but still count it” and “banner off, list keeps it” are both possible.
- Do not disturb (one switch, account-wide) — beats everything. The order is Do not disturb → mute → loudness, and no envelope takes a shortcut around it.
There is no un-muteable urgent channel. Nothing a publisher sends can override your mute or Do not disturb — including messages that call themselves urgent or security-related. If a topic is not worth hearing right now, mute it; the only thing louder than your settings is removing the follow.
Your preferences follow you: mute, hide, loudness and digest live on the follow, so a topic muted on the macOS client is quiet on Android and the web inbox too. Muting is the reversible version of unsubscribing.
Digest
Per topic, off (default), hourly, or
daily, bucketed in UTC. With a digest on, envelopes still land in the
inbox the moment they arrive — the digest changes only the notification: no live
banner per message, then one summary ping at the end of the bucket
(N updates: the last few titles). Opening that ping opens the inbox for
that topic.
Loudness, mute and Do not disturb apply to the summary ping exactly as they apply to a live banner, and a ping suppressed at fire time is simply dropped — the envelopes are already waiting in the inbox. A fire missed while the app was asleep arrives as one ping when the client next runs, not as a backlog.
Limits · Topics and subscribers
The free plan is generous but not unlimited, and the limits are enforced rather than documented-only:
| Limit | Value | When you hit it |
|---|---|---|
| Topics per account | 4 private + 3 public | Creating one too many is refused. The development environment raises the private allowance so the operator can run real topics; the product tier that ships with billing is stricter. |
| Live subscribers per topic | 10,000 (soft cap) | A WebSocket connect past the cap is refused with
429 {"error":"subscriber_cap"}. Existing listeners keep streaming;
fan-out never attempts more than the cap. |
| Envelope size | ≤ 2 KB | See Message structure. |
| Body size | ≤ 1 MB | Oversized bodies are rejected at publish. |
The subscriber cap is soft: it bounds live WebSocket fan-out, not reading. A topic past the cap still accepts publishes and its envelopes are still in the inbox and history — what is refused is one more simultaneous connection.
Limits · Monthly usage
Each account has a monthly allowance, counted per UTC calendar month:
| Meter | Free allowance per month | Charged to |
|---|---|---|
| Envelopes published | 10,000 | The publishing account |
| Body fetch | 2 GiB | The topic owner — a listener opening bodies spends the owner's allowance, not their own |
| Stored (published bytes) | 1 GiB | The publishing account |
Going over is refused with a 429 carrying the meter that tripped:
envelope_limit, fetch_limit or storage_limit.
Nothing is silently dropped and no overage is billed — this version has no payment path at
all (paid features return 501), so a limit is a wall, not an invoice.
Current usage is yours to read:
GET /v1/me/usage returns the period, the limits, and per-day counters —
envelopes, stored bytes, fetch bytes, deliveries, opens and unique opens (the
receipts roll up here too). The web inbox shows the same
summary under Account.
The fetch_limit charge is the one that surprises people: a popular public
topic whose bodies many people open spends the owner's fetch allowance. Publish
envelopes with short previews and let readers open only what they need, or keep bodies
small — images count against the body cap too.
Contact
hello@orbnet.app ·
support@orbnet.app ·
abuse@orbnet.app ·
security@orbnet.app.
Magic links come from noreply@orbnet.app only.