---
title: "Ask the hard question from your phone: deep research on demand, answered while you do something else"
series: Build It Yourself — briefs
brief: 08
license: CC BY 4.0
tested_on: Claude Code 2.1 (Oct 2026) · Node 26 · macOS 26 — steps 1–6 built and run by a fresh headless session (the claude CLI replaced by a fake script with five outcomes, chat sender replaced by a fake notify script and a log file, no network or paid API, hang case run with a 2–3 s cap override); step 7 (scheduler and chat wiring) written but not run
effort: one evening (4 h) for the queue, runner, result guarantee and delivery; +1 h for the watchdog and the chat wiring
---

# Ask the hard question from your phone

## TL;DR
You send one message — *"which e-bike for a 12 km commute with two steep hills, budget 2,500?"* —
and the assistant answers in one line: "On it, back in a few minutes." Behind that line a
separate headless Claude Code run grinds for as long as the question needs: searches, reads
pages, compares, writes a note you can reopen later. Then the answer lands back in the same chat.
The research itself is the easy part. What this brief builds is everything around it that decides
whether you can *trust* the silence in between: a job inbox, a runner that cannot hang or die
quietly, a result file that exists even when the run failed, a delivery that is proven by a real
message id, and a watchdog with no model inside it. We lost days to each of those before they
worked.

---

## What you get (a scene)

> **You (11:02, on the bus):** "Research: best e-bike for a 12 km commute, two steep hills,
> up to 2,500, I carry a 10 kg bag. Compare the top three, say what you'd buy."
>
> **Assistant (11:02):** "On it — back in a few minutes. No need to ask again."
>
> *(You put the phone away. At 11:09:)*
>
> **Assistant:** "Short answer: bike B. Mid-drive motor handles both hills without cooking the
> battery; A is lighter but its hub motor is rated for flat terrain; C is 400 over budget.
> Confidence: medium — two of the three spec sheets disagree with the retailer pages. Sources: 6.
> Full notes: research/2026-10-27-e-bike-commute.md"
>
> **You (11:15):** "What about the second one with a bigger battery?"
>
> **Assistant:** answers from the saved note in seconds, without researching again.

---

## Prerequisites

- The always-on machine, the chat channel, `notify.sh` and the bridge from **brief 00**
  (`start-here-always-on-agent.md`).
- Claude Code installed and logged in on that machine (`claude -p "say ok"` must work from a
  plain shell — the runner uses exactly that).
- A folder for notes that survives the job (we use plain Markdown files; any notes app that
  reads a folder works).
- `perl` (ships with macOS and most Linux). Reason in trap 7.

---

## Architecture (the 15-line version)

```
 chat ──"research: …"──▶ bridge ──▶ submit.js ──▶ jobs/<id>.json      (atomic write, deduped)
   ▲                         │ ack: "on it, few minutes"                      │ file watcher / 15-min sweep
   │                         ▼                                                ▼
   │                  (chat turn ENDS)                 runner.sh  claim by mv → processing/
   │                                                      │   cap: wall-clock + turns + dollars
   │                                                      ▼
   │                                          claude -p  (web + files, headless)
   │                                                      │ writes notes/<date>-<slug>.md  and results/<id>.json
   │                                                      ▼
   │                                   finalize.js  guarantees a result file: ok | capped | error(real cause)
   │                                                      ▼
   └──── message id proof ◀──── deliver.js ──▶ notify.sh   + followups.json (next chat turn sees it)

 watchdog.sh  (no model)  queued too long · stuck running · undelivered · login expired · own stderr
```

1. **The chat turn never does the research.** It acknowledges and enqueues, then ends.
2. **Files are the protocol.** A job is a JSON file, claiming it is a `mv`, the answer is a file.
   Everything is inspectable with `ls` and `cat`, and survives a reboot.
3. **Every run leaves a result file**, success or not. A missing result is a bug the watchdog
   reports.
4. **Code delivers, the model only writes.** A send counts only with a real message id.

---

## Build steps — paste each block into Claude Code, in order

### Step 1 — the inbox: one atomic, deduplicated, self-contained job

```text
Create ~/agent/research (Node 20+, no dependencies; small bash scripts where noted).
Directories: jobs/ processing/ results/ done/ notes/ logs/ logs/out/ fixtures/.
config.json: {"model":"sonnet","max_turns":60,"max_budget_usd":5,"deadline_min":30,
"dedupe_window_min":20,"min_task_chars":60,"claude_bin":"claude"}.

submit.js: `node submit.js --task "<text>" --by <name> [--reply-to <chat id>] [--deadline-min N]`.
- Job = {id:"job-<unix>-<pid>", task, requested_by, reply_to, deadline_min, created_at,
  task_hash}. Write it to jobs/.<id>.tmp and rename to jobs/<id>.json — the runner only globs
  *.json, so it can never read a half-written file.
- Refuse a task shorter than min_task_chars with exit 2 and the message "Task is too short to be
  self-contained. The research run cannot see the chat — include every fact it needs."
- Dedupe: task_hash = sha256 of the task lower-cased with whitespace collapsed. If a job (or a
  result) from the same requester with the same hash exists in jobs/, processing/, results/ or
  done/ and is younger than dedupe_window_min, print "DUPLICATE <existing id>" and exit 0
  without writing anything.
- On success print "QUEUED <id>" then, on its own line, the exact one-line ack the chat should
  send: "On it — back in a few minutes. No need to ask again."
`node submit.js --list` prints every job with its state (queued / running / done / failed) and age.
Add `node --test` tests: atomic write (no .tmp left behind), duplicate within the window,
duplicate of an already finished job, too-short task, same task from a different requester.
```
**Check:** submitting the same sentence twice prints `DUPLICATE`; `ls -a jobs/` never shows a
half-written file; a five-word task is refused with exit code 2.

### Step 2 — the runner: claim, cap, and never pipe the output away

```text
Create ~/agent/research/runner.sh (bash) and fixtures/fake-claude.sh. The runner reads
claude_bin from config.json, or from $CLAUDE_BIN if set (tests use the fake).
1. Orphan recovery first: any file in processing/ older than cap_min+10 minutes belongs to a
   dead process (reboot, crash) — mv it back to jobs/ and log a line.
2. Loop: take every jobs/*.json; claim with `mv jobs/<id>.json processing/<id>.json` (the mv IS
   the lock — if it fails, someone else won, move on); IMMEDIATELY `touch` the claimed file
   (mv keeps the old mtime, and age checks must measure running time, not queue time).
   After a pass, re-glob jobs/ and repeat until it is empty.
3. cap_min = max(30, 2 * deadline_min from the job). Run the model as
   `perl -e 'alarm shift; exec @ARGV' $((cap_min*60)) <claude_bin> -p "<RUNNER-PROMPT.md + the
   job JSON>" --model <model> --allowed-tools "WebSearch,WebFetch,Read,Write,Grep,Glob,Bash"
   --max-turns <max_turns> --max-budget-usd <max_budget_usd> > logs/out/<id>.log 2>&1`.
   Redirect the output to the file. NEVER pipe it through tail/head: the pipeline would return
   the pipe's exit code instead of the model's, and the one line that says what went wrong
   would be dropped.
4. Record rc, then call `node finalize.js <id> <rc> <start_epoch> <cap_min>` (built in step 3)
   and finally mv processing/<id>.json to done/. One line per phase to logs/runner.log.
fixtures/fake-claude.sh takes $FAKE_MODE and behaves like the real CLI's outcomes: `ok` (writes
notes/<slug>.md and results/<id>.json, exit 0), `silent` (prints nothing, exit 0), `auth` (prints
"Failed to authenticate: OAuth session expired and could not be refreshed", exit 1), `maxturns`
(writes a note, then prints only "Reached max turns", exit 1), `hang` (sleeps 600 s).
The job id is the last "id" in the prompt text it receives.
```
**Check:** drop two jobs in the same second — both are processed by one runner invocation; with
`FAKE_MODE=hang` and a job whose deadline is tiny (give the runner a test-only
env override of the cap, a few seconds, and say so in the file) the runner kills it, rc is 142 in the log, and the
queue moves on.

### Step 3 — finalize: every run leaves a result, with the real reason

```text
Create ~/agent/research/finalize.js: `node finalize.js <id> <rc> <start_epoch> <cap_min>`.
If results/<id>.json already exists and parses, leave it alone (add "rc" if missing). Otherwise
SYNTHESIZE one, deciding in this order:
1. The log (logs/out/<id>.log) contains "Reached max turns": if any file in notes/ is newer than
   start_epoch, write {status:"ok", capped:true, artifacts:[those notes], summary:"The run hit its
   turn limit after saving notes — the answer is in the notes, the summary was never written.
   <first 600 chars of the newest note>"}. If there is no new note it is a real failure:
   status:"error", cause "hit the turn limit with nothing saved".
2. rc == 142 → status:"error", cause "killed after <cap_min> minutes (hung, not slow)".
3. Otherwise cause = the last non-empty line of the log (max 300 chars); if the log is empty
   and rc == 0: "exited silently with no output — possible causes: login expired, usage limit,
   crash". Result = {status:"error", died:true, rc, error: cause, summary: a plain sentence for
   a human that names the cause}.
Never write the word "quota" or "limit reached" unless the log itself says it.
Also write one JSON line per finalized job to logs/engine.log: id, status, rc, capped, seconds.
Tests (use fake-claude.sh with each FAKE_MODE): ok leaves the model's own file untouched;
silent → the "exited silently" cause; auth → the cause contains "OAuth session expired";
maxturns with a note → ok+capped, without a note → error; hang → cause names the minutes.
```
**Check:** for each of the five modes, `results/<id>.json` exists and `summary` is something you
would be willing to read on your phone.

### Step 4 — the prompt: what the headless run must do, in what order

```text
Create ~/agent/research/RUNNER-PROMPT.md, the instructions prepended to every job, and
validate-result.js. The prompt must say, in plain imperative sentences:
1. Do the task in the job's `task` field; you cannot ask questions and nobody is watching.
2. Research properly: at least 5 distinct sources for any comparison or recommendation, prefer
   primary sources (spec sheets, official docs, filings) over reviews, and open the page — do
   not rely on a search snippet. When two sources disagree, say so and say which you trust.
3. ORDER OF WORK — deliver first, bookkeep last: (a) write the answer into notes/<yyyy-mm-dd>-<slug>.md
   as soon as you have it (sections: Answer · Why · Options compared · Sources · What I could not
   verify); (b) THEN write results/<id>.json = {id, status:"ok", summary (<= 900 characters,
   chat-ready, written exactly as the assistant would say it, first line = the answer),
   confidence:"high|medium|low", sources:[urls], artifacts:[note path]}; (c) anything else last.
   You may run out of turns at any moment; whatever is not saved by then is lost.
4. Say what you could not verify. A confident answer built on one retailer page is worse than "low
   confidence — here is what would settle it".
5. NEVER use the Bash tool's run_in_background option, and never end your turn saying you will wait
   for something. Nothing wakes a headless run up. For anything slow, start it detached
   (`( cmd; echo $? > marker.rc ) & disown`) and poll with ordinary short Bash calls.
6. Do not message anyone and do not send anything: delivery is done by code after you finish.
7. If the task is impossible, write results/<id>.json with status:"error" and one honest sentence.
validate-result.js: `node validate-result.js results/<id>.json` exits non-zero and lists problems
when an "ok" result has no summary, a summary over 900 characters, an empty `sources`, a missing
`confidence`, or an artifact path that does not exist. finalize.js (step 3) must call it for
model-written results and add the problems as a `warnings` array — never discard the answer.
Add tests, including an ok result with no sources (kept, but warned).
```
**Check:** `grep -c "run_in_background" RUNNER-PROMPT.md` is at least 1 (the ban names the flag);
a hand-written result with an empty `sources` array is kept and carries a warning.

### Step 5 — delivery: proven by a message id, and remembered for follow-ups

```text
Create ~/agent/research/deliver.js: for every results/*.json without "delivered_message_id":
- Build the chat text with code: the result's summary, then "Sources: <n> · Confidence: <c>" for
  ok results, then "Full notes: <artifact path>" if any. For status:"error" send
  "Research failed: <error>. Nothing was lost; ask again when fixed." — the real cause, in
  plain words, never an invented one.
- Split anything over 3500 characters on paragraph boundaries (chat limits are real).
- Look up reply_to from the matching job in done/ or processing/ and call
  `~/agent/notify.sh` (under --stub: append to sent.log with a fake id `stub-<n>`). Take the
  message id from notify.sh's output.
- State machine on the result file, written via temp+rename: before sending set
  delivery:"sending"; after a real id comes back set delivered_message_id and
  delivery:"delivered"; on failure set delivery:"failed", delivery_attempts+1 and the error.
  Retry failed ones up to 3 times, 10 minutes apart. A result stuck in "sending" is never re-sent
  automatically (a crash between the send and the write would produce a duplicate) — it is shown
  by `node deliver.js --status` for a human or the watchdog.
- On delivery append {id, task (first 200 chars), summary, note_path, delivered_at} to
  followups.json (keep the newest 10).
Also export `recentContext(maxAgeHours=24, n=3)` → a text block "Recent research you delivered:
<task> → <summary> (notes: <path>)". The chat bridge prepends it to the next user message, so
"what about the second one?" is answered from the note, not by researching again.
Tests: a send that fails twice then succeeds ends with one message id; a crash simulated between
send and write leaves "sending" and sends nothing on the next run; a 9000-character summary
arrives as three messages with one recorded id for the first.
```
**Check:** `sent.log` has exactly one line per chunk; `node deliver.js --status` shows nothing
undelivered; `recentContext()` prints the task and the note path.

### Step 6 — the watchdog: no model inside it

```text
Create ~/agent/research/watchdog.sh (bash + node one-liners, NO model call anywhere in it; it
exists for the moments when the model is down). Every run checks:
1. A job in jobs/ older than 15 minutes (nothing picked it up).
2. A file in processing/ whose mtime is older than cap_min+10 (stuck or orphaned).
3. A result with delivery "failed" 3 times, or "sending" for over 10 minutes.
4. A done/ job with no results/<id>.json (the runner broke its own rule).
5. The last 5 error results all contain "authenticate" or "expired": alert "Claude login
   expired — open a terminal on the machine, run `claude`, log in again". This is the one cause
   only a human can fix; say so.
6. Self-check: grep its OWN stderr log (logs/watchdog.err) and the runner's for
   "command not found|syntax error|unbound variable|No such file|Permission denied" — a watchdog
   cannot report its own death, but it can read what it printed.
Alerts go through notify.sh with a 6-hour cooldown per key kept in watchdog-state.txt, and print
the same line to logs/watchdog.log. `--dry` prints what it would send. If notify.sh itself fails,
fall back to a macOS notification (osascript) or `wall` on Linux, and log it.
Tests with backdated fixtures (touch -t): each of the six checks fires once, the second run is
silent (cooldown), --dry sends nothing.
```
**Check:** back-date a queued job by 20 minutes, run the watchdog twice — one alert on
`sent.log`, one cooldown line in the log.

### Step 7 — schedule it and wire the chat (written, not run)

```text
Wire the real thing: (a) launchd (macOS) or systemd path units (Linux): run runner.sh when a file
appears in jobs/, plus a 15-minute interval as a fallback; run deliver.js when results/ changes
and every minute; run watchdog.sh every 5 minutes; log one line per run to logs/. (b) In the chat
bridge from brief 00, route a message starting with "research:" — or, if you prefer, any request
the assistant judges will take more than a minute — to submit.js with the sender as --by and the
chat id as --reply-to, send the ack line it prints, and END the turn. Never start the research in
the chat turn. (c) Prepend deliver.js recentContext() to every following chat turn. Show me the
diff before applying anything.
```
**Check:** a real "research: …" message gets the ack within seconds and the answer within minutes;
`logs/runner.log` shows start and done lines.

---

## The traps we hit (read these twice)

**1. Researching inside the chat turn.** *Symptom:* a long question made the chat session stall
and die mid-task; the person got nothing and the next message hit a broken session. *Cause:* a
chat turn has a timeout and shares its context with the conversation. *Fix:* the chat turn only
acknowledges and enqueues (step 7); the work runs in a separate headless process.

**2. The job cannot see the chat.** "What about the cheaper one?" sent as a task is meaningless to
a run that never saw the conversation. *Fix:* the task must be self-contained — everything the run
needs is in the job text — and `submit.js` refuses tasks that are too short to be.

**3. Re-running because it feels slow.** A research run takes minutes of silence, and silence
invites a second submission: two identical runs, two budgets, two answers. *Fix:* the ack line
says "no need to ask again", and `submit.js` dedupes by hash within a window (step 1).

**4. Two jobs in one second, one run.** Two jobs written together can coalesce into a single
file-watcher trigger; the second sat until the fallback sweep, a quarter of an hour later, and the
watchdog called it stuck. *Fix:* the runner re-globs the inbox after every pass until it is empty.

**5. `mv` keeps the old mtime.** A job that waited behind five others in the queue arrived in
`processing/` already "an hour old" and was flagged stuck — or worse, reclaimed as an orphan and
run twice while still running. *Fix:* `touch` the file the moment you claim it.

**6. A reboot orphans the job.** The runner and its child die together; the claimed file sits in
`processing/` forever and the board says "working". *Fix:* orphan recovery at the top of every run —
anything older than the cap plus ten minutes belongs to a dead process (step 2).

**7. A deadline that nobody enforces.** We wrote `deadline_min` into every job for weeks; a hung
run held the whole queue for hours. macOS has no `timeout` command. *Fix:* a wall-clock cap
through `perl -e 'alarm shift; exec @ARGV'` — the alarm survives `exec`, the run dies with code
142, and the result file says "hung, not slow".

**8. The pipe that ate the error.** `claude -p … | tail -3` made the exit code always 0 and threw
away the one line that said "OAuth session expired". Thirteen runs died and every result read
"produced no result (rc=0)". *Fix:* redirect to a log file, read the exit code of the model, and
let `finalize.js` quote the last real line (steps 2–3).

**9. Hitting the turn limit is not dying.** With `--max-turns` exhausted, the CLI prints only
"Reached max turns" — no result, no final message — even if the work was finished and saved. Two
runs were counted as failures after they had already written their output. *Fix:* order of work
in the prompt (answer first, bookkeeping last) and a finalizer that checks for saved notes since
the start before calling it a death. Never read a max-turns exit as a diagnosis.

**10. `run_in_background` in a headless run.** The tool promises "you'll be notified when it
finishes". In a batch run nothing ever notifies. The model used it for a slow step anyway, ended
its turn, and the job vanished without a result. Describing the right way (detach, then poll) was
not enough — the prompt had to name the flag and forbid it. Rule 5 in step 4.

**11. Telling a model to "send it".** A run told to deliver its answer replied "I'm sending it…"
and sent nothing, exit code 0. *Fix:* code sends, keeps the message id, and only then marks the
result delivered; the model writes and never sends (step 5). Same reason a stuck `sending` is
shown to a human instead of resent: a duplicate is worse than a late line.

**12. The silent outage.** Two days of every job exiting after one second with "OAuth session
expired", and no alert — because the thing that raised alerts was itself one of the jobs that
needed the login. *Fix:* a watchdog with no model inside, which reads the real cause from the
results and tells you the one thing only a human can do (step 6).

**13. An answer with no home.** The chat never saw the run, so a follow-up ("what about the second
one?") would start from nothing and pay for the research again. *Fix:* durable notes saved as
files, and `recentContext()` puts the last few results in front of the chat's next turn (step 5).

**14. The note written in the same second the run started.** Found by the test build, not by an
outage: the finalizer asked "is any note newer than the start time?" with a strict comparison, and
file times are whole seconds — a fast run that saved its note in the start second looked like it
had saved nothing and was reported as a death. *Fix:* compare with "not older than the start".

**15. The watchdog that re-reads its own old errors.** Also from the test build: a self-check that
greps the whole stderr file finds the same old line on every run and alerts every cooldown, forever.
*Fix:* remember the byte offset of the last read and look only at what was added since.

**16. The runner that cannot find its own config.** The runner changes into the state directory to
claim files, and a relative `config.json` then pointed at nothing. *Fix:* resolve the script's own
directory first and read everything configuration-like from there.

---

## You know it works when

- [ ] Submitting the same question twice in a row queues once.
- [ ] Two jobs dropped in one second are both processed by one runner pass.
- [ ] A hung fake run is killed, the result says so, and the next job still runs.
- [ ] A fake "login expired" run produces a result that quotes "OAuth session expired" and the
      watchdog tells you to log in again.
- [ ] A run that saved notes and then hit the turn limit is delivered as "ok, capped".
- [ ] Every delivered answer has a real message id in its result file.
- [ ] Asking a follow-up in chat is answered from the note without a new job.

## Extend it

- **Scheduled research.** The same inbox takes a job from cron: a Monday "what changed in X this
  week" that lands in your notes and in one chat line.
- **A reading list.** Give the run a folder of your own documents as the first source.
- **Budget awareness.** Read your usage meter before starting a long job and defer it when the
  window is nearly full, with an honest "later" instead of a failure.
- **Several voices.** Per-requester notes folders and a `--by` allow-list, so a household shares
  one engine without sharing answers.
- **Fact-check pass.** A second, cheaper run reads only the sources list and flags claims the
  pages do not support.

## Changelog

- 2026-10-27 — first public version. Tested on the versions in the header.

---

*Part of **Build It Yourself** — free briefs on building a personal AI Chief of Staff, one
capability at a time: https://chief.natalicot.com/briefs. Free, forever. · License CC BY 4.0*
