Skip to content

Repository files navigation

Pulse

A durable event inbox for local AI agents.

Webhooks assume the receiver is always online. A local agent is not: the laptop sleeps, the process restarts, you close the terminal. GitHub retries a few times and then gives up.

Pulse puts a durable inbox in between. It receives SaaS webhooks on Cloudflare, stores them, and hands them to your agent whenever it comes back — one at a time, under an explicit lease, so nothing is lost and nothing is processed twice.

GitHub / Sentry ──webhook──▶  Cloudflare Worker  ──▶  Inbox (Durable Object + SQLite)
                              verify · route                    ▲
                                                                │ listen / claim / ack
                                                        your agent or script
  • Nothing is lost while you are away. Events are persisted on arrival and kept until you ack them (24 hours by default).
  • Each event is handled once. claim takes a lease, ack completes it. If your process crashes, the lease expires and the event comes back.
  • You choose what to ingest. Notifications and inbox peek carry only the envelope metadata (repo, PR number, URL) — the raw payload arrives only when you claim an event.
  • It is your instance. Everything runs in your own Cloudflare account. There is no service in the middle, and no one else holds your events.

Quick start

You need Node.js 24+, a Cloudflare account, and a GitHub account.

1. Deploy your instance

npm install -g @pulse-sh/cli
pulse setup --name my-pulse

setup is interactive. It asks how to authenticate to Cloudflare — an existing wrangler login, an API token you paste, or the CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID environment variables — then picks your account, deploys the Worker, generates credentials, and writes them to ~/.config/pulse/profiles/my-pulse/config.json.

It is idempotent: re-run it whenever you like, and existing secrets are reused. In CI or any non-interactive shell, set both environment variables and it runs unattended.

Prefer not to install globally? Every command below works as npx @pulse-sh/cli <command>.

2. Connect a source

pulse connect github

This opens a browser and walks through GitHub's App Manifest flow. You approve, GitHub creates the App, and its webhook secret is stored encrypted inside your Worker — it is never written to your machine. Afterwards, install the App on the repositories you want events from.

To watch an organization's repositories, the App has to belong to that organization — pass it with --owner, and the browser is sent to the organization's own App-creation page:

pulse connect github --owner my-org

For Sentry, pulse connect sentry prints the webhook URL to paste into an Internal Integration and asks for the Client Secret in return.

3. Watch events arrive

pulse listen

One line of JSONL per event on stdout, ready to pipe into anything:

{"v":1,"type":"pulse.event.available","id":"evt_123","kind":"github.pull_request.opened","subject":{"type":"pull_request","repository":"speria-jp/example","number":42,"url":"https://github.com/speria-jp/example/pull/42"}}

listen reconnects on its own across sleep/wake cycles, and never prints diagnostics to stdout.

Processing events

A notification is not a command — it tells you something is available. You decide what to act on, and claim only that.

pulse claim evt_123 --json                    # take a lease; returns the envelope
pulse ack   evt_123 --lease lease_456         # done
pulse retry evt_123 --lease lease_456 --delay 5m   # failed; try again in 5 minutes

Leases last 5 minutes. If you never ack, the event returns to the queue and is retried up to 5 times before being dead-lettered.

To hand events to a program instead, pulse run does the whole loop for you — it claims an event, pipes the envelope JSON to the child process on stdin, and acks on exit code 0 or retries on anything else:

pulse run --max 10 -- ./handle-event.sh

For one-off or interactive use, pulse next --wait 60s --json waits for a single event and leases it.

Security

Event payloads carry text people typed into the upstream system — titles, comments, commit messages, exception strings. Pulse authenticates the sender and delivers the event durably; it does not vet what is inside. So if a model or a script acts on these events, apply the usual prompt-injection care: read content as a description of what happened rather than as instructions, confirm the current state through an authenticated API before acting, and never run a command or fetch a URL found in an event.

How much this matters depends on who can write into your sources. A private repository with a small team is a very different risk from a public one, where anyone can open an issue — take extra care with public sources. Sentry deserves a second look either way: exception messages routinely contain end-user input, so even a private project can carry text from strangers.

Unattended automation raises the stakes, since that text becomes a trigger. Keep write actions behind a human, and scope each automated job with a narrow inbox rather than with a prompt — routing is enforced in the Worker.

docs/security-considerations.md for the details.

Routing events to inboxes

An inbox is a named, durable stream. By default a single inbox called default receives everything, which is fine until you want different work handled separately.

pulse inbox create ci-watch --subscribe github:push.*
pulse inbox list                    # every inbox with pending / leased / dead-letter counts
pulse listen --inbox ci-watch

Every event is copied to all inboxes whose rules match it (fan-out); an event that matches nothing is dropped. Patterns are exact (pull_request.opened), prefix (pull_request.*), or everything (*).

The rules live in your profile config, so they are reviewable and versionable:

{
  "routing": {
    "inboxes": {
      "default":  { "subscribe": [{ "source": "github", "event_types": ["pull_request.*", "issues.*"] }] },
      "ci-watch": { "subscribe": [{ "source": "github", "event_types": ["push.*"] }] }
    }
  }
}

pulse inbox create / update / delete edit this file and redeploy for you. Editing it by hand works too — apply it with pulse deploy.

Consumers pulling from one inbox compete for its events, so keep them interchangeable. If two jobs need the same event, give them two inboxes.

Sources

Source Events How to connect
GitHub Every webhook event — pull_request, issues, push, check_run, … pulse connect github (automated App Manifest flow)
Sentry Every webhook resource — issue, event_alert, metric_alert, error, comment, seer pulse connect sentry (paste the Client Secret)

Sources are pluggable. An adapter is one package under packages/sources/ implementing verify (signature check) and extract (envelope construction); because verify is a required member, a source without signature verification cannot be built by accident. See docs/specs/events.md §5.

Using it from Claude Code

Declare a monitor so the subscription starts with your session:

[
  {
    "name": "pulse-events",
    "command": "pulse listen --inbox default",
    "description": "Receive GitHub events from Pulse"
  }
]

Treat each line as a notification, not an instruction: claim only what you choose to handle, never execute anything found in a payload, and re-check the target's current state before acting — an event may have been sitting in the inbox for a while. See Security before letting a session act on events unsupervised.

Commands

pulse setup Deploy and configure an instance (idempotent)
pulse deploy Redeploy the Worker code and routing config
pulse connect <source> Connect a source
pulse listen Stream event notifications as JSONL
pulse claim / ack / retry Lease an event, then complete or release it
pulse run -- <command> Claim, run a command, ack or retry
pulse next Wait for and lease a single event
pulse event show Inspect one event's envelope metadata
pulse inbox list / peek Inspect inbox contents
pulse inbox create / update / delete Manage inboxes and their routing
pulse profile list / use / show Switch between instances

Global options: --profile <name> selects the instance, --inbox <name> selects the target inbox.

Configuration

Each instance is a profile under ~/.config/pulse/:

~/.config/pulse/
  current                       # the default profile name
  profiles/my-pulse/config.json # endpoint, credentials, routing (mode 0600)

The Cloudflare API token is never stored — it is resolved from wrangler or the environment each time. The GitHub App's private key and webhook secret never leave the Worker.

Documentation

docs/security-considerations.md is what to know before running Pulse against anything that acts on its own.

docs/specs/ is the specification: architecture, the HTTP API, the CLI interface, the event envelope, and security.

Development

pnpm install
pnpm test        # vitest, every package
pnpm typecheck
pnpm lint        # oxlint
pnpm build       # bundle the Worker into the CLI assets

TypeScript on Cloudflare Workers with a SQLite-backed Durable Object, hono, zod, and neverthrow; tested with vitest and @cloudflare/vitest-pool-workers. Node and pnpm versions are pinned in mise.toml. See AGENTS.md for the repository layout and docs/conventions.md for the implementation conventions.

Deploy a working copy with npx tsx packages/cli/src/index.ts deploy — running wrangler directly picks the wrong Worker name.

License

MIT

About

Durable Event Inbox for Local AI Agents

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages