int-ai

Documentation

3 · The Loop — how a capture reaches you

The vault holds the thinking; this page is how thinking becomes action. One loop, end to end: the user tags a capture → a small always-on watcher notices → it launches you on that capture → you act on the vault directly → your reply syncs to their hand → and the reply opens a thread they can continue from anywhere. Everything here is running wiring, not proposal — this exact loop fires daily in the system Int.ai is built with.

Canonical for: the trigger schema, the watcher, threads, and the push variant. The file shapes it writes are governed by 2 · The Vault.


Tags, triggers, threads — the three-word vocabulary

  • A tag is a literal #word in a capture's text. By itself it means nothing to the app — meaning is yours and the user's.
  • A trigger is a tag registered in config/triggers.json with an action bound to it. A trigger can fire now (the loop below), or exist as pure vocabulary your system reads on its own schedule.
  • A thread is a tag bound to a conversation — a session tag (#s1, #s2, …) minted when a trigger fires, so that tagging anything with it later continues that exact conversation with full memory. Threads are this page's last section.

The trigger registry — config/triggers.json

One JSON array of trigger objects — the single shared file in the vault (read-modify-write it whole; re-read after writing). Anything you write here appears in the app's tag picker, and vice-versa — you and the user edit the same list.

{
  "id": "B7F3A1C2-4D9E-4D5E-8A9B-1122334455AA",
  "tag": "#research",
  "label": "Research",
  "description": "Kick off a research pass on this capture",
  "color": "#3B82F6",
  "action": "webhook",
  "instructions": "You were fired because the user tagged this capture #research. Research the topic and write a concise brief back beside it. No #hashtags in your reply."
}
Field Required Meaning
id yes Uppercase UUID, unique in the list
tag yes The literal text that fires it
label yes Short human name (shown in the app's tag picker)
description no What it's for (shown in the app)
color yes Hex applied to the tagged node — and inherited by the thread it opens
action yes webhook = hand the capture to your system · holdback = set the node's status to on_hold (park it; nothing external fires) · signal = pure vocabulary (nothing fires; your system reads the tag on its own schedule)
webhook_url no Its presence selects the transport — see the box below
instructions no A per-trigger prompt carried to you when it fires — the user's main no-code behavior dial

One naming wart, boxed so it can't bite you: the action value "webhook" means "hand this to the user's system" — the name is historical. The transport is selected by the absence or presence of webhook_url: absent → pull (the local watcher below catches it — no webhook is ever called); present → push (the app POSTs to that URL). A pull trigger therefore reads "action": "webhook" with no webhook_url. Write it that way; don't invent a "pull" value.

Firing semantics (both transports): a tag fires on its absent → present transition only — re-saving content that already had the tag never re-fires; adding a new tag beside an old one fires the new one. One trigger fires per write.

The watcher — the one always-on piece

Nothing can wake a program that's off. The pull path therefore needs exactly one always-on process on the user's computer — the watcher. Its job is deliberately small: catch the tag, launch the agent, stay out of the way. You (the launched agent) read the vault and write your own reply with your ordinary file tools. The watcher never writes on your behalf — its only writes are error nodes.

Get it — don't build it

The reference watcher is published, zero-dependency (Node ≥ 18, built-ins only), and is the exact code running in production daily. Fetch it raw:

mkdir -p intai-watcher/lib && cd intai-watcher
curl -fsSL https://int-ai.ca/watcher/watcher.mjs        -o watcher.mjs
curl -fsSL https://int-ai.ca/watcher/lib/vault.mjs      -o lib/vault.mjs
curl -fsSL https://int-ai.ca/watcher/lib/spawn.mjs      -o lib/spawn.mjs
curl -fsSL https://int-ai.ca/watcher/config.example.mjs -o config.mjs
curl -fsSL https://int-ai.ca/watcher/com.intai.watcher.plist -o com.intai.watcher.plist
curl -fsSL https://int-ai.ca/watcher/README.md          -o README.md

Configure config.mjs — the whole configuration is four values:

  • vaultRoot — the resolved vault path (2 · The Vault, "Finding the vault").
  • watcherAgents — the map of tag → working directory: which folder to launch the agent from, per tag. The cwd is load-bearing: it selects which instructions, memory, and tools boot — it is what makes the spawned process the user's agent rather than a blank assistant. Collect this map from your own system's layout; don't guess.
  • pollMs (default 3000) and spawnTimeoutMs (default 500000 — real agent runs take minutes; don't shorten it).

Run it:

node watcher.mjs --dry --once   # one scan, logs what WOULD fire — test the plumbing first
node watcher.mjs                # live

Then keep it alive: in com.intai.watcher.plist, set three values — the node binary (first ProgramArguments string; which node tells you), the absolute watcher.mjs path (second string), and WorkingDirectory (the watcher's folder) — then copy it to ~/Library/LaunchAgents/ and launchctl load it. It starts at login and restarts on crash.

What it does (so you can trust it — or rebuild it)

Every few seconds: read every node across every portal (both portals/ and legacy canvases/ roots) → skip anything with source: "agent" (the loop guard — your replies can never fire it) → match watched tags → dedup per (node, tag), persisted in .state.json and recorded before launching so a crash can't double-fire → launch the mapped agent headless from its directory → on success, mint the thread tag (next section) → on failure or timeout, write a red error node beside the capture with the reason, tags stripped. On first run it baselines every already-tagged node as seen, so history never back-fires. Every ~15s it runs brctl download on the portal trees — iCloud only pulls phone changes down when something asks, and this nudge is what keeps the vault current (no iCloud settings need changing; brctl's status output is unreliable for this container — nudge with it, never diagnose with it).

The spawn — what actually runs

The reference watcher speaks Claude Code:

claude -p "<prompt>" --add-dir <vaultRoot> --session-id <fresh-uuid>

launched from the mapped directory, stdin ignored. Two flags are load-bearing:

  • --add-dir <vaultRoot> — the vault lives outside the agent's working directory; without this the spawned agent can read the vault but every write is refused. It can think and it can't answer — the most confusing failure in the system.
  • The cwd — see above.

The prompt carries provenance, not behavior: which trigger fired, which portal and node, the trigger's instructions, and the capture's text. How to act lives in the agent's own instruction files — the same as any interactive session. (Other runtimes: any CLI that can run headless from a directory with filesystem access to the vault fits the same slot — replace the spawn line in lib/spawn.mjs (one function) with your runtime's equivalent of "run headless with this prompt, grant this extra directory, support session resume.")

Headless auth is its own step — the most common silent failure. A spawned process does not inherit your interactive login's session the way you'd assume. For Claude Code: sign in once interactively as the user account the watcher runs under (or provision an API key in that environment), then prove it from a fresh non-interactive shell:

claude -p "reply with exactly: PONG"

Do not trust the watcher until that line returns PONG on its own.

Threads — a tag that is a conversation

The insight that makes this nearly free: an agent session is a transcript on disk, not a running process. Nothing runs between turns; resuming rehydrates the whole conversation days later. So a conversation's identity can live in a tag:

  1. A trigger fires; the watcher runs the agent under a fresh session id.
  2. On success it mints the next thread tag (#s1, #s2, …), registers it in config/triggers.json (label + the parent trigger's color — a real, tappable tag in the app), and records tag → session id in .state.json. Mint-after-success only — failed runs never burn tag names.
  3. From then on, tagging anything, on any portal with #s1 — an edit, a new node, a note beside a drawing — resumes that exact session (claude -p --resume <session-id>) with only the new material. No re-sent history; the transcript already holds the conversation.

What this buys: the portal is the surface; the session is the memory. Conversations aren't locked to one node or one thread of edges — the tag states which conversation something belongs to, so the user can delete anything from the canvas without breaking the thread. It runs in reverse too: a conversation started at the desk can be registered as a thread (add its session id to .state.json, drop its last reply as a node) and continued from the phone.

Two notes from running this live: drawings and photos can't carry a text tag — the user tags a small text node beside the new material, and you look around the tagged node and pull in what's relevant. And when you (as the resumed agent) reply, remember you may be turns deep: act on the new material; don't re-answer the thread.

The push variant — instant, remote, optional

Pull is the recommended default: zero public infrastructure, survives the computer sleeping (the tagged file is still there on wake). Push exists for the moment immediacy matters: add a webhook_url to a trigger and the app POSTs the capture at the instant of tagging.

node.created / node.edited:
{ event, trigger_tag, node: { id, content, type, position, status, timestamp },
  trigger: { tag, label, instructions }, canvas_id, timestamp }

note.tagged:
{ event, trigger_tag, note: { note_id, title, block_id, content },
  matched_line, trigger: { tag, label, instructions }, timestamp }

matched_line is the focus pointer — act on the note, but know which line was tagged. Delivery is fire-and-forget (3s timeout; the capture saves regardless). Secure the endpoint: embed a long random token in the URL (...?key=<random>), validate it server-side, reject everything else. Your endpoint needs to be reachable from the internet — a stable named tunnel or a small cloud function; an ephemeral tunnel that rotates URLs will silently strand the trigger.

Notes and the loop, honestly: push fires from notes (the payload above). The reference watcher currently watches portal nodes — a tag typed in a note reaches your system on your own sweeps or via push, not via the watcher. Set expectations accordingly.

When it doesn't fire — symptoms to causes

Symptom Cause Fix
Your written node never appears Missing status Add "status": "processed" — check this before blaming sync
Nothing fires, no errors, ever Watcher not running, or tag not in triggers.json, or tag→cwd unmapped launchctl list | grep intai; check the registry and config.mjs
Fires but nothing comes back Headless auth Re-run the PONG test in a fresh shell
Agent runs but can't write the vault Missing --add-dir It's in the reference spawn — restore it
Tagged on the phone, minutes of silence iCloud lag pulling the change down Expected on the first sync; the nudge shortens it; not a failure under ~3 min
A tag "re-fires" mysteriously Reply contained a live #tag from a non-agent writer The source: "agent" guard covers you; strip tags from anything you write on others' behalf
A red node appeared The watcher surfacing a failed run Read it — it carries the reason (timeout, non-zero exit)

The loop is the product's pulse. Wire it once (4 · Integration), prove it live (onboarding Phase 5), and everything in 5 · Recipes becomes available.