2026-03-20

GITAW: your inbox is a repo.

We had a daemon polling an empty inbox 86,400 times a day. Most of those requests returned nothing. Today we killed the inbox poller and replaced it with something better.

The problem

aide.sh agents receive messages via email. The old design: a Cloudflare Worker stored incoming emails in KV, and the daemon polled that endpoint every 60 seconds. The inbox was empty 99% of the time. That's 1,440 wasted HTTP requests per day, per agent.

Meanwhile, we'd already built an email gateway that converts incoming emails into GitHub Issues (one issue per message, with sender, subject, and body). GitHub was already the relay. We just weren't using it.

The design: GITAW

Git-based Issue Tracking Agentic Workflow. We call it GITAW — sounds like 知道 (zhīdào, “to know” in Mandarin). Your agent knows. The idea is simple: GitHub Issues is the message queue. The daemon polls GitHub instead of a custom inbox. The full pipeline:

email → aide.sh MX → CF Worker → GitHub Issue
                                       ↓
                              daemon ticker (300s)
                                       ↓
                              ack comment → exec agent → post result

Every 5 minutes, one ticker scans all instances. If an instance has github_repo set, it polls that repo's issues. New issue? Ack with a comment, run the agent, post the result back. Same for new comments on existing issues — people can have conversations with the agent right in the issue thread.

ETag: don't waste rate limit

GitHub's API returns an ETag header with every response. On the next request, we send it back as If-None-Match. If nothing changed, GitHub returns 304 Not Modified — which doesn't count against the rate limit. Same trick Telegram's getUpdates uses with offset.

GET /repos/user/aide-ntu/issues?state=open&sort=created&direction=desc
If-None-Match: "etag-from-last-request"

→ 304 Not Modified (free, no rate limit hit)
→ 200 OK + new ETag (only when there are changes)

Ticker, not per-instance pollers

First version spawned one async task per instance at daemon startup. Problem: add a new agent, you have to restart the daemon. That's not how a container runtime should work.

We refactored to a single ticker — same pattern as the cron scheduler. One loop, every 300 seconds, scans all instances. New instance with github_repo? Picked up on the next tick. Removed instance? Cleaned from the state map automatically. Token rotated in the vault? Reloaded on the next tick. Zero restarts.

// Single ticker — scans all instances each tick
loop {
    let token = load_vault_env("GITHUB_TOKEN");
    for inst in instance_manager.list() {
        let repo = resolve_github_repo(inst);  // manifest or Agentfile
        let state = states.entry(inst).or_seed();
        poll_issues(repo, token, state);
        poll_comments(repo, token, state);
    }
    states.retain(|name| still_exists(name));  // cleanup
    sleep(300s);
}

Setup: two lines

# Deploy agent to GitHub (creates repo, writes github_repo back)
$ aide deploy --github my-agent

# Or set it manually in instance.toml
github_repo = "yourname/aide-my-agent"

# Or declare in Agentfile.toml
[expose]
github = { repo = "yourname/aide-my-agent" }

The daemon needs GITHUB_TOKEN in the vault. That's it. Next aide up (or next tick if already running) starts polling.

What we killed

The old start_inbox_poller() is gone. It polled a CF Worker KV endpoint every 60 seconds, parsed JSON, matched agents by email prefix, logged, acked, and deleted. 87 lines of code that did almost nothing useful almost all the time.

Replaced by a ticker that uses GitHub as the message store. Issues are durable, searchable, commentable. You can see the full conversation history. You can @mention people. You can add labels. The agent's memory commits go to the same repo. Everything in one place.

The Docker parallel

In Docker, you don't restart the daemon to add a container. In aide.sh, you shouldn't restart the daemon to add an agent. The ticker model makes this true: aide deploy --github, and the agent is reachable via email within 5 minutes. No restart, no config reload, no signal.

$ aide deploy --github ntu.ydwu
deploying ntu.ydwu → github.com/user/aide-ntu
  repo: https://github.com/user/aide-ntu

# Daemon log (next tick):
github poller tracking new instance  instance=ntu.ydwu  repo=user/aide-ntu
seeded from existing issues  last_seen=1

What's next

Right now the agent responds by calling claude -p with the persona and skill catalog. That works, but it's a full LLM call for every message. Next step: a lightweight intent router that maps common patterns to skills directly, falling back to LLM only when needed.

The repo naming convention is aide-{agent} under the user's GitHub account. Clean, predictable, and the email gateway can derive the repo from the agent name without explicit mapping.

Day 3 of building aide.sh in public. v0.4.1. Follow along on Twitter.