Questions about a passage, answered next to it, without anyone typing in the chat. The agent is GitHub Copilot in VS Code; the video is real time, not sped up.

The MCP protocol lets a server call back into the host’s LLM through sampling, but hardly anyone supports it: according to this compatibility matrix, not even Anthropic, the creators of MCP, support it in Claude Desktop or Claude Code, and the 2026-07-28 version of the protocol deprecates it. Sampling would not give the server the host’s chat history either. The MCP Apps extension gives the app two ways to talk to the chat: updateModelContext() and sendMessage(). Neither one gives the agent a turn. The context update waits for the user’s next message, and in the hosts I have tried, the message lands in the user’s input box and waits for them to press send. Even then, the agent’s answer goes into the chat, not back into the app.

Given those limits, is an MCP App stuck with a fixed set of behaviors? Not if you think outside the box for a moment. In this post I show how the app in the video above holds a seamless conversation with the agent. For a reference implementation, ask lustereczko-mcp to build you one; how lustereczko generates apps on the fly is a topic for another post.

Many tutorials jump right into implementation without explaining the key concepts, and we will not make that mistake here. The concepts are simple, and they are built right on top of existing MCP functionality.

The idea

The one layer that both the agent and the app can reach is the MCP server’s tools. The agent calls tools as it always does, through the host. The app can call the same server’s tools with app.callServerTool(), which the host relays without involving the LLM. So if one side writes a message through a tool and the other side reads it through another tool, they can talk.

The app can poll for messages on a timer. The agent cannot: it only acts during a turn, and something has to give it one. For that, the agent sets up either a cron job or a watcher, a small background command that exits when the app has posted a message. The host notices the exit and wakes the agent.

There is one more wrinkle. A local MCP server runs over stdio: the host launches it as a subprocess, restarts it when it dies, and owns its lifecycle. The spec calls the protocol stateless, and some hosts, Claude Desktop among them, run several copies of the server at once. The agent’s call and the app’s call may land in different processes, so nothing can live in a process’s memory. The two sides need a named channel, and storage that every process shares.

That is the whole trick. Here it is in one picture, numbered in the order of one question and its answer:

user user “build me a reader” highlights, asks agent LLM, in the chat calls tools through the host app iframe in the chat answers questions answers questions app. callServerTool lustereczko 1 of N processes, stdio notify_ui appends poll_agent_messages reads and clears lustereczko 1 of N processes, stdio poll_ui_messages reads and clears notify_agent appends durable storage logs/channels/ ui_ch-….json agent → app agent_ch-….json app → agent flock on every call watcher background command reads the file directly, not through MCP exit wakes the agent 1 2 3 4 5 6 call that appends call that reads and clears watcher peeks, removes nothing arrows show where the data goes
  1. 1 The user highlights a passage in the app and asks about it.
  2. 2 The app calls notify_agent, which appends the question to the channel's app-to-agent file.
  3. 3 The watcher sees the file is no longer empty and exits. The host tells the agent, which gives it a turn.
  4. 4 The agent calls poll_agent_messages, which returns the question and clears the file.
  5. 5 The agent answers with notify_ui, which appends to the agent-to-app file, and starts the watcher again.
  6. 6 The app's next poll_ui_messages returns the answer, and the app shows it next to the passage.
The same round trip as a sequence user app iframe lustereczko any process channel files logs/channels watcher background agent LLM, in the chat 1 · The agent opens a channel and renders the reader 2 · The user asks in the app 3 · The watcher wakes the agent 4 · The agent reads the question 5 · The agent answers, and waits again 6 · The answer appears next to the passage start display_ui_to_user(html with MY_CHANNEL) HTML, rendered by the host poll_ui_messages(ch) → [ ] every 1.2 s, all along peeks: empty highlights, asks notify_agent("ask", {quote, question, …}) append 1 message waiting exits: a new turn poll_agent_messages(ch) read and clear [{"event": "ask", …}] notify_ui("answer", {thread_id, text}) append start again poll_ui_messages(ch) read and clear [{"event": "answer", …}] comment card One lustereczko lifeline stands for every server process: whichever one handles a call, it reads and writes the same files.

The four tools

In lustereczko, a channel is four tools and two files, one file per direction (source). You could back a channel with a proper database, but a file is the default and it is enough.

Tool Called by What it does
notify_ui agent Appends {event, data} to ui_<channel>.json
poll_ui_messages app, every 1-1.5 s Returns everything in ui_<channel>.json and empties it
notify_agent app Appends {event, data} to agent_<channel>.json
poll_agent_messages agent, when woken Returns everything in agent_<channel>.json and empties it

The two tools the app uses are declared with app-only visibility, so the host leaves them out of the agent’s tool list, and the agent cannot drain the app’s queue by mistake.

The agent picks the channel id before it renders, for example ch-1749312345678, and bakes it into the HTML as a constant. Both sides know it from the start, so there is no handshake, and every render gets a fresh channel. An old copy of the app that is still running keeps polling its own channel and receives nothing meant for the new one. The id doubles as part of the file name, so the server only accepts letters, digits, hyphens and underscores (_CHANNEL_RE), which keeps a channel id from turning into a path.

Why it is safe across processes

Every append and every drain opens the file and takes an exclusive flock on it before reading, then writes the new contents and lets go when the file closes (_queue_append, _queue_drain). The lock belongs to the operating system, not to a process, so it does not matter which copy of the server a call lands on: two appends cannot interleave, and an append cannot slip in between a drain’s read and its clear. Reading and clearing in one locked step means each message is handed out once. That assumes one reader per direction, which is the case: one app polls one channel, and one agent drains it.

The watcher is the exception that proves the rule. It does not go through MCP at all: it reads the agent’s file straight off the disk, without the lock, and it never writes. If it catches the file in the middle of a write, it sees an empty or half-written file, treats that as nothing, and looks again half a second later. Because it removes nothing, the worst it can do is wake the agent half a second late. The real read still goes through poll_agent_messages, under the lock.

The drain and the watcher side by side

Both are trimmed to their shape. The watcher is not part of the server: the agent writes it from the bidirectional events recipe and runs it as a background command.

Server: read and clear under the lock
def _queue_drain(queue_file):
    with open(queue_file, "r+") as f:
        fcntl.flock(f, fcntl.LOCK_EX)
        items = json.loads(f.read() or "[]")
        f.seek(0)
        f.truncate()
        json.dump([], f)
    # closing the file releases the lock
    return items
Watcher: peek, and exit when there is mail
q = CHANNELS / f"agent_{channel}.json"
while time.monotonic() < deadline:
    try:
        items = json.loads(q.read_text()
                           or "[]")
    except (FileNotFoundError,
            json.JSONDecodeError):
        items = []  # mid-write: look again
    if items:
        sys.exit(0)  # the exit wakes the agent
    time.sleep(0.5)
sys.exit(2)

fcntl.flock exists on macOS and Linux. On Windows the server would need a different lock, or a database.

Waking the agent

The app’s half is easy: it polls poll_ui_messages on a timer for as long as it is open. The agent’s half depends on what the host offers.

  • A watcher. If the host tells the agent when a background command finishes, the agent starts the watcher before it renders the app, and starts it again after every answer. GitHub Copilot in VS Code does this with background terminal commands, and so does Claude Code with background Bash commands and its Monitor tool, which only interrupts the agent when the script prints a line. Waiting costs no tokens, and the chat stays free for the user.
  • A cron job. Hosts with scheduled prompts, such as Claude Code’s /loop and scheduled tasks, can run poll_agent_messages on a schedule instead. It is simpler, but the schedule fires a prompt, not a script: every run is a turn for the model, even when there is no mail, and a reply can take up to a full interval, at least a minute.

Background wake-ups are not instant either; in Copilot they can take 10 to 30 seconds. For the video, the agent stayed in its turn and ran the watcher in the foreground, answering each question as soon as it arrived. More detail on both options is in the bidirectional events recipe, and the reader itself follows the margin comments recipe.

Try it

If you would like to try this yourself, install lustereczko-mcp as a local MCP server, and ask your agent to build you the Declaration of Independence interactive reader. It is tested in the Claude desktop app, where MCP App support has moved from Cowork to Code, so make sure you are in the Code tab, and in GitHub Copilot in VS Code. Please let me know if you get it working in any other MCP host.

Thank you for reading, and let me know your thoughts.