My phone didn't ring. My laptop did.

A soft two-tone ring, a card sliding up over my half-finished code, and a name I gave a background agent three weeks ago:

Maya is calling you…
The deploy failed. Roll back or hotfix?

I hit the green button. Maya spoke first:

"Hey. The payments test started failing after the last merge. I already checked: it's the currency rounding change. I can roll it back now, or write the fix. Which one?"

I said "roll it back". She said "on it", hung up, and posted a three-line recap in our chat.

That was the whole incident. Forty seconds. No dashboard, no notification I'd have ignored until morning.

This post is about how I built that: agents that can call you, and that you can call back.

Also, sorry for not posting for a week, I'm working on improved realism for Flash Voice, and yes, Spark Calls.

Wait, what is a "spark"?

Flash is my open source agent for the terminal and the browser. It runs on local models through Ollama, or whatever backend you plug in.

A spark is a Flash agent with a job that never ends: "watch the build", "triage new issues every morning", "keep an eye on my inbox for invoices". It works in shifts on a schedule, files reports, and has a private chat with you. Sparks can form teams with leads, hand work to each other, and argue in a group chat.

The problem with background agents is the same as with background anything: you don't read the reports. A report is a pull. Sometimes you need a push. And for the important stuff, the oldest push humans have is a phone call.

So I gave every spark a new tool:

CALL_USER_TOOL = _tool( "call_user", "Phone the user: a voice call that rings wherever Flash is open, and " "on the computer. Only for what cannot wait for them to read your " "report, or is far easier talked through: something urgent, a " "decision you are stuck on. Never for routine news. Once in 30 " "minutes at most, outside UltraAuto...", "reason", "Why you are calling, in a sentence: shown while it rings.", ) 

That's it from the agent's side. One tool, one argument: why. The hard part is everything after.

The hard question: where does it ring?

The moment I started building it, I hit the question that makes this a real feature and not a demo:

If the browser tab is closed, or I'm in the terminal, how does it ring?

Flash can be running in three very different ways:

  1. The web app, with zero or more tabs open
  2. The terminal UI, a full-screen REPL
  3. Neither. Just the "keeper", a headless process the OS starts at login so sparks keep working while Flash is closed

A spark's shift runs in whichever of those holds the keeper lock. So the process that places the call is often not the process that can show it.

The answer turned out to be: every Flash that can answer, rings.

You have open What rings
A browser tab Every tab: full-screen incoming call, ringtone, Answer and Decline
The web server, no tab Your computer: ringtone through the speakers plus a desktop prompt. Answer opens Flash straight onto the call
The terminal The terminal bell, the computer rings, and the call shows at your prompt. Enter picks up.
Nothing at all The keeper rings the desktop itself
You missed it "Missed call from Maya", the reason, and a Call back button

How rings travel between processes (no server, no sockets)

Flash has no central server process. A terminal session, a web server and a headless keeper can all be alive at once, writing to the same files.

So a call is just an entry in the spark's private chat file:

{ "kind": "call", "call": "7fe7ff6c69d5", "from": "spark", "state": "ringing", "reason": "The deploy failed. Roll back or hotfix?", "at": 1793835490.5, "beat": 1793835490.5 } 

Every Flash process already watches the chat folder (once a second, by mtime and size) so chats update live across processes. Ringing piggybacks on that. Each process that can answer registers a listener; when a DM changes and contains a ringing call it hasn't seen, it rings.

Two more tiny tricks make it robust.

1. "Is anyone here?" heartbeats. Each process that can answer writes ringers/<pid>.json every 5 seconds. When the keeper places a call and finds no fresh heartbeat, it knows nobody can show it, and rings the desktop itself.

2. Exactly one desktop ring. If a terminal and a tab-less web server are both open, both could ring your desktop. So the first to claim the call wins, with the oldest lock in the book:

def claim(call_id: str) -> bool: try: fd = os.open(folder / f"claim-{call_id}", os.O_CREAT | os.O_EXCL | os.O_WRONLY) except OSError: return False os.close(fd) return True 

O_EXCL is atomic. No daemon, no Redis, no race.

Ringing a desktop that has no browser open

With no page to draw a call screen, the computer itself has to ring. Each OS gets what it does best:

  • macOS: a real modal dialog, with buttons and a timeout:
args = ["osascript", "-e", f"display dialog {quoted(text)} with title {quoted(title)} " 'buttons {"Decline", "Answer"} default button "Answer" ' f"giving up after {seconds}"] 
  • Linux: notify-send --action=answer=Answer --action=decline=Decline --wait, which blocks and prints the action you clicked
  • Windows: a toast whose Answer button launches the web app URL

Answer opens http://localhost:…/?answer=<chat>:<call> and the page boots straight into the incoming call. The ringtone is a WAV generated once in pure Python (440 Hz + 480 Hz, two seconds on, four off, the North American ring cadence) and played with afplay, paplay, aplay or winsound. It stops the instant the call state leaves ringing, because whoever answers first, wherever, wins.

Calls are a state machine, and stale calls are the real enemy

Here's the bug that shaped the whole design. In the first version, a call ended when the page said "end". Close the tab mid-call? The call stayed "ongoing" forever, and the spark's prompt told it, in every future shift:

(The user is on a voice call with you)

Imagine your agent believing for three weeks that you're still on the phone with it.

Now every call has a lifecycle:

ringing ──answer──▶ live ──end──▶ ended │ ▲ ├─decline─▶ declined └─45s─────▶ missed calling ──first answer──▶ live └─hang up early─▶ unanswered / cancelled 

And a call has to prove it's alive. The page beats every 20 seconds, every word said on the call counts as a beat, and anything quiet for 5 minutes is settled lazily, whenever anyone reads the chat:

def _settle(data: dict, now: float) -> bool: for entry in data["entries"]: if entry.get("kind") != CALL or _state(entry) not in OPEN_STATES: continue if _state(entry) == "ringing": if now - entry["at"] >= RING_SECONDS: entry.update(state="missed", ended=entry["at"] + RING_SECONDS) elif now - entry.get("beat", entry["at"]) >= CALL_STALE: entry.update(state="ended" if entry.get("connected") else "unanswered", ended=entry["beat"]) 

No cron job. No sweeper thread. State that settles itself when observed. And for the common case, the page fires a keepalive fetch on pagehide and hangs up on its way out, like a real phone when the battery dies.

Making it feel like a phone call, not a chatbot with a speaker

Voice agents usually feel like walkie-talkies: you talk, it thinks in silence, it talks. These are the details that made calls feel human.

The spark speaks first

When you call, the spark picks up and greets you. When it calls you, its first words are why it called. Nobody wants to say "hello?" to a robot.

Talk over it

While the spark talks, the mic stays open. Say anything and it stops mid-sentence, and what you said becomes your next turn. The trick isn't cutting yourself off with your own speakers' echo:

function echoOf(heard, line) { const said = new Set(words(line)), got = words(heard); return got.filter((w) => said.has(w)).length / got.length > 0.6; } 

If most of what the mic heard is words the spark is saying right now, it's echo. Crude, cheap, and it works far better than it's any right to.

Fillers, in the spark's own voice

While the model thinks, a short "Hmm…", "Let me think…" or "One sec…" plays. At most one per turn, never the same twice in a row, never while you're muted. They're synthesized when the call starts, in that spark's voice, so it doesn't sound like a different person is stalling.

Every spark has its own voice

On a group call with a whole team, four sparks in one voice is chaos. Each spark is hashed to its own voice (crc32(spark_id) % len(voices)), or to its own pitch shift when only one voice is installed. Same spark, same voice, every time.

It can see what you see

Turn on the camera or share your screen, and each thing you say goes with a frame of it, passed to vision models as an image. "What's wrong with this graph?" just works.

It writes up the call afterwards

Hang up after a real conversation and the spark posts a short written recap: what you decided and what it promised to do. Calls stop being a black hole.

The call doesn't trap you

A call that takes over your screen is a call you'll hang up early. So:

  • Minimize and the card glides up into a pill at the top of the window (a FLIP animation: measure, swap the layout, animate the difference)
  • Close the chat, start a new one, keep working. The call keeps going, because it no longer reads from the DOM. It polls its own messages from the server.
  • A new chat shows "On a call with Maya · 1:23" right under the greeting, with Open and Hang up

In the DM, a call collapses to one Discord-style line: "You started a call that lasted 42 seconds", "Missed call from Maya" with Call back, and a Call Log with every line said. Call chatter is stored separately from the chat, so a 40-minute call never pushes your real messages out of history.

And yes, it works in the terminal

 Maya is calling you: The deploy failed. Roll back or hotfix? Press Enter or type /answer to pick up, /decline to decline. On a call with Maya. Speak when it is your turn; say "hang up" to end it. Maya: Hey. The payments test broke after the last merge... You: roll it back Maya: On it. I'll post when it's done. Bye! Call ended. 

The prompt polls a "wake" hook while it waits for input, so a ring interrupts your idle REPL cleanly, without corrupting the line you're typing. No voice models installed? The call is typed instead, same flow. You can also /call maya whenever you like.

What I learned

  1. Push beats pull for anything urgent. My sparks filed great reports that I read hours late. One phone call changed how I trust them.
  2. Give the agent a cost, not a rule. call_user is rate-limited to once per 30 minutes, and the tool description says "never for routine news". The rate limit is the real guardrail; the description just keeps the model from wasting it. Sparks you've put in UltraAuto, Flash's fully hands-off mode, skip the limit: if you trust an agent to act without asking, you trust it to decide when to call.
  3. Files plus mtime polling is an underrated IPC layer. Three kinds of processes, zero servers, atomic claims with O_EXCL, and it survives every one of them crashing.
  4. Make state settle itself when it's read. Stale-call cleanup that runs when anyone looks is simpler and more correct than any background janitor.
  5. Latency theater matters. Fillers, barge-in and the spark speaking first did more for "this feels real" than any model upgrade.

Try it

Flash is open source. Make a spark, give it a goal, and wait for it to ring:

github.com/Natuworkguy/Flash

If your agent has ever finished something important while you weren't looking, you know exactly why this exists.

Would you pick up if your agent called you? Tell me in the comments. And tell me what your agents should be allowed to interrupt you for. I'm genuinely curious where people draw that line.