You give Claude Code a long job: rename a module across 80 files, then run the migrations. You go and make lunch. Halfway through it reaches a step it should not take alone, a migration that drops a table, and it stops to ask. The question sits in a terminal tab behind three windows for 40 minutes.
The agent can post a banner with a shell command, but a banner cannot carry a button press back. What it needs is one narrow tool: ask the person at this Mac, wait, return the answer.
Watchflows can hand it that tool. With AI Agents on, the app runs a small MCP server on your Mac, and any flow you expose becomes a tool an assistant can call. The flow here shows your question as a notification with Yes, No and a typed reply, then returns what you chose.
Connect Claude Code to Watchflows over MCP
Open Settings ▸ AI Agents and turn on Enable AI Agents. Until you do, nothing listens. Connect your AI assistant lists a snippet per client. The Claude Code one is a single command:
claude mcp add --transport stdio watchflows -- /Applications/Watchflows.app/Contents/MacOS/watchflows-mcp
Copy it from the pane rather than from here, since the pane builds the path from wherever your copy of the app actually lives. There is no token in it. watchflows-mcp is a small program inside the app that reads the token from a file only your user account can read (mcp-token in ~/Library/Application Support/Watchflows/). If the app is not running, the shim launches it hidden and waits about ten seconds for the server. Keep running in the background makes that rarer by registering a login item.
What Claude Code can call
Five core tools read and run, and none of them can create, edit or enable a flow:
| Tool | What it does |
|---|---|
list_workflows | Every flow, with agentRunnable marking the ones it may run |
describe_workflow | Nodes in order, and the exact inputs a run takes |
run_workflow | Runs one by id, waits up to 60 seconds |
get_run | The full record of one run |
list_runs | A flow's run history |
Each exposed flow also becomes its own tool, named after the flow in snake case. This one is called Ask Me, so Claude Code sees mcp__watchflows__ask_me, with one string argument, text.
Importing the flow does not expose it. Open it, select nothing, and turn on Expose to AI agents in the flow's inspector (or in the pane's Exposed watchflows list). The switch is not stored in the .watchflow file, on purpose.
Build the Ask Me flow
Question is an Input trigger with Input Type Text. Because it is an Input trigger, the tool gets a typed text argument instead of a loose payload object. The saved text is a sample question, used when you press Run yourself or when a caller leaves text out.
Ask me is a Notification Prompt. Title is Claude Code is asking, Message is {{text}}, and Buttons has Yes and No on two lines, with No listed under Destructive Buttons so it draws in red. Allow a typed reply is on, with the Reply Button renamed Answer, for the times the right answer is "no, do this instead". Give up after (seconds) is 1800.
Answer is a Template on the Action outlet, with Output Key result and this text:
button: {{action}}
reply: {{text}}
Dismissed is a second Template on the Dismissed outlet that sets result to dismissed: closed without an answer.
The key has to be result. Unless you mark a node Return this to the agent, a finished run hands back output from the one node that ended the path, and only its result key. A flow that ends in a plain Notification returns output_source: none:side-effect and no answer at all. Only one outlet fires, so only one Template runs and there is exactly one end node.
Take a question as text from an Input trigger, ask me in a notification with Yes and No buttons and a typed reply, wait up to 30 minutes, and put my answer in a key called result. If I dismiss the notification, set result to "dismissed".
When you are slow, typing, or away
- You take longer than a minute.
run_workflowand the per-flow tool wait 60 seconds, then answerrunningwith arun_id. The run is not failing, it is waiting for you. Calling the tool again returnsalready_runningwith the same id, so the agent should pollget_runinstead. - You never answer. After 30 minutes the Notification Prompt fails the node and the run ends
failed. Timing out never takes the Dismissed outlet, because a question nobody saw is not a no. - You type a reply.
actionis the Reply Button's label,Answer, andtextis what you typed. The prompt removes the incomingtext(the question) before it adds its own, so the question can never come back as your reply. - The banner slides away. Set Watchflows to the Alert style in System Settings ▸ Notifications. With the style set to None, or notifications denied, the node fails at once rather than waiting on something you cannot see.
Claude Code will not guess any of this, so tell it in your project's CLAUDE.md:
Before any destructive or irreversible step, call the watchflows ask_me tool
with the question as text. If it returns status running, poll get_run with the
run_id every 30 seconds. Treat "dismissed" or a failed run as "no" and stop.
Who else can call it
The server listens only on 127.0.0.1, port 52916. It refuses a request unless the Host header names loopback, there is no Origin header (which rules out web pages in your browser), and the bearer token matches. The token survives relaunches and updates. Rotate, under Advanced in the pane, replaces it, and every client then has to reconnect.
A run also needs the flow to be enabled and exposed. The read tools still list every flow by name, so an assistant knows what exists, but an unexposed flow has no tool of its own and run_workflow refuses it. See AI Agents (MCP) for the full contract.
Variations
- A done ping. Replace the prompt with a Notification and Claude Code can tell you a long task finished. Nothing comes back, and nothing needs to.
- Structured input. Switch Input Type to JSON and the argument becomes
data, an object. Read fields as{{data.branch}}. - One deploy, nothing else. Replace the prompt with a Run Script that calls your deploy script. The agent can start that deploy and no other command, and its
textreaches the script as$PAYLOAD_text, never pasted into the shell. - Several fields. Use a Form trigger instead of Input, and each field becomes its own named argument.