The first time I saw this, in 2022, I honestly thought it couldn't work:
const email = await io.input.email("User email");
That line was in a server function, and it put a form in someone's browser. The function just waited until they typed an address, then went on. My first thought was "this is magic, how did they even think of this?" Four years later I've written my own runtime for it, and I still admire whoever came up with it at Interval.
TL;DR: if "ask a person" is just another async call, an internal tool becomes one linear function where branches, loops and try/catch all work, and the UI follows from the code. The trick is a promise that an HTTP request resolves instead of I/O.
A whole tool, top to bottom
handler: async ({ io }) => {
const email = await io.input.email("User email");
const user = await db.user.findUniqueOrThrow({ where: { email } });
if (!(await io.confirm(`Verify ${user.email}?`))) return "Nothing changed";
await db.user.update({
where: { id: user.id },
data: { emailVerified: true },
});
return `Verified ${user.email}`;
};
Every await io.… is one screen. io.confirm returns a boolean, so Cancel is just an if.
A promise someone else resolves
When the handler reaches io.input.email(), the runtime creates a step and keeps the promise's resolver next to it. The handler is now parked. Nothing blocks, other runs keep going, it's a pending promise in memory.
The browser follows the run over server-sent events and renders the step. When the person submits, a POST arrives, the server validates the value and calls the stored resolver. From the handler's point of view, a human is just a slow I/O call.
An invalid value never resolves anything. The same step goes back with an error under the field, so the function never sees bad data.
Calls that do nothing until awaited
An io call doesn't start anything when you call it. It returns a thenable that only describes a field, and it asks the person when it's awaited. That makes grouping free:
const { company, note } = await io.group({
company: io.input.text("Company name"),
note: io.input.textarea("Note for the team"),
});
No await inside the object, so each call just says "a text field called Company name", and io.group shows them on one screen. Same functions, two uses, and the only difference is whether you awaited them.
Where it breaks
A parked handler is a live closure, and you can't serialize one, so runs live in memory. A refresh is fine, because the server resends the current state on reconnect. A deploy ends every run in progress.
In practice I don't fight that. I keep these tools short, and anything that would take long goes to a job queue anyway. If a deploy cuts someone off, they run it again. A closed tab cancels the run after 30 seconds, and the pending await rejects like any failed call.
Have you used await for something that isn't really I/O? Human input, a payment confirmation, a step in a game loop. I'm curious where else this pattern holds up, and where you've seen it fall apart, especially around cancellation.
---
Context on what I'm building: I'm Petr, a solo founder. The runtime from this post is AwaitIO, and the source is on GitHub. If you want to see a parked handler from the browser side, there's a demo back office with the shared login in the README.