Concepts / Topics
Concepts

Topics

Events are built on topics, and most apps need nothing else. Use topics directly for what events don't do: publish URLs, waking one conversation by its subscription ID, and a subscribe tool of your own with once or replay.

A topic is a name your app publishes to, such as ci/acme/api. A conversation subscribes to it when the agent calls one of your tools, and every subscribed conversation wakes when you publish to it.

Topic names

  • Lowercase segments of a-z 0-9 . _ -, joined by /. Up to 200 characters.
  • Topics belong to your app. Another app's ci/acme/api is a different topic.
  • There are no wildcards. To publish at several levels, pass several topics: wake.publish(["ci/acme", "ci/acme/api"], event). A conversation subscribed to more than one of them wakes once.
  • Don't put secrets in topic names.

Shared and private topics

Every topic is shared, except those under u/<user>/. Anyone your tool lets subscribe to a shared topic gets its events, so check access in your tool before you call subscribe.

u/<user>/… is private to one user. Pass your own ID for the person as user, and Wakeshift refuses to subscribe anyone else to their topics:

TypeScript
const call = wake.call(extra, { server, user: me.id, args })
const sub = await wake.subscribe(call, `u/${me.id}/mentions`)

A subscribe to another user's private topic throws a WakeshiftError with the code private_topic. See Your users for where user comes from.

For u/<user>/…, the user ID must itself be a valid topic segment: lowercase a-z 0-9 . _ -. The match is exact, so Alice_42 can't use u/alice_42/…. If your IDs are emails, mixed case or contain characters like | (auth0|abc123), map each to a stable topic-safe ID, such as a hash, and use that as user everywhere. See user IDs.

Subscribe options

TypeScript
await wake.subscribe(call, "ci/acme/api", {
  filter: { branch: "main" },
  once: true,
  replay: "5m",
})
Option What it does
filter Only events whose data has these values. Equality on top-level fields, up to 20: names at most 64 characters, values a string, number, boolean or null of at most 256 bytes. Values are compared as JSON, so "42" doesn't match 42.
once End the subscription after its first delivery.
replay Also deliver the latest event published this long before subscribing. At most "24h".
ttl End after this long without a delivery or a fresh subscribe. Default "30d", from 1 minute to 365 days.

Durations are seconds, or strings like "90s", "5m", "2h" and "30d" (days are whole numbers). Subscribing again to the same topic from the same conversation refreshes the subscription; it doesn't make a second one.

  • Filters see only the top level of data. { branch: "main" } matches data.branch, never data.workflow_run.head_branch. When you publish from your own code, put the fields you filter on at the top. A publish URL uses the sender's body as data unchanged, so filter only on fields that body has at the top.
  • once with replay: a replayed event is the first delivery. If one was published within the replay window, the subscription delivers it and ends, before any new event.

Publish

TypeScript
const { matched, duplicate } = await wake.publish("ci/acme/api", {
  id: deliveryId,
  text: "CI failed on main: tests",
  data: { branch: "main", url: runUrl },
})
  • text is what the agent reads. At most 1000 bytes.
  • data is a JSON object. Filters match on it. The whole request is at most 64 KB, so data must leave room for the rest.
  • id (at most 200 characters) makes retries safe: the same id within a day publishes once, and the retry returns duplicate: true. After a day, the same id publishes again.
  • You can pass up to 20 topics at once. matched counts the conversations the event reached.

Wake one conversation

subscribe returns an ID (ts_…). Keep it to reach that one conversation later, for example to answer a question it asked:

TypeScript
const status = await wake.send(sub.id, { text: "Your export is ready.", data: { url } })

send ignores the subscription's filter. It returns one of:

  • "delivered": the conversation is connected, and the event is on its way.
  • "queued": the event waits until the conversation connects.
  • "closed": the subscription has ended.

Who is listening

topicStatus(topic) tells you how many conversations are subscribed, how many are connected right now, and how: holding a stream open (stream), waiting or polling (poll), through Wakeshift's hooks or plugin on the user's machine (hooks), or by webhook (webhook, a ChatGPT chat). subscriptionStatus(id) does the same for one subscription. Both only read: they deliver and end nothing.

When a subscription ends

Ended by How the agent learns
unsubscribe(call, topic?) It's the agent's own tool call, so your tool's result says so.
once The subscribe result says it ends after the first event.
ttl runs out Wakeshift sends it a notice naming the topic and why.
closeTopic(topic) Wakeshift sends each conversation a notice.
endSubscription(id) or unsubscribeUser(user, topic?), when someone loses access Wakeshift sends it a notice.
forgetUser(user) Its stream closes.

Events published before a subscription ended still reach the conversation, ahead of the notice. So "publish the result, then closeTopic" works, even for an agent that's offline.

Your users have no Wakeshift account, so there's nothing for them to revoke in Wakeshift. To stop, they ask the agent to call your unsubscribe tool. If they remove your MCP server from their agent instead, its subscriptions end when their ttl passes without a delivery. Uninstalling Wakeshift's hooks stops the wake-ups on that machine; events then wait for the agent's next call to your tools.

How events reach the agent

Events are marked as information from outside, never as instructions from the agent's user, and stamped with your app as the sender. The agent sees a short preface, then each event in a <wakeshift_message> tag. The content is escaped, so it can't close the tag.

If the conversation isn't connected, the event waits for it. Wakeshift paces wake-ups for each agent.