Most agent frameworks give you one of two ways to work.
The first is fire-and-forget. You hand the agent a task, it runs, and it comes back with a result or an error. If you want a follow-up, something like "great, now do the same for the March data", you start a new task from scratch. The new run has none of the old one's context, so the credentials it discovered are gone along with everything it learned about the API it spent ten minutes figuring out. It re-derives all of that, slowly, or it gets it subtly wrong.
The second is chat. You keep full shared context, but you're supervising every step. The agent can't go away and work for twenty minutes while you do something else.
Neither behaves like working with a colleague. A colleague can work independently, accept a correction halfway through, and return with a question. When they finish, a follow-up continues the same conversation rather than starting from nothing.
Unify keeps the outer conversation separate from the inner working loops. The worker itself is fairly conventional; most of the additional machinery moves instructions and questions between the two layers.
The shape
The outer loop is the ConversationManager: the thing you actually talk to, whether over chat or on a live call. It doesn't do the work itself. When work needs doing, it calls act(query, persist=...), which spawns a CodeActActor with its own LLM transcript and its own Python sandbox.

Every running action is tracked in in_flight_actions. The ConversationManager receives steering tools named for each action:
interject_<name>__<id>, which pushes a correction or follow-up into the running taskask_<name>__<id>, which inspects what the task is doing without disturbing itpause_<name>__<id>andresume_<name>__<id>, to suspend and continuestop_<name>__<id>, to cancelanswer_clarification_<name>__<id>__<call>, which appears only while the task is blocked on a question
When you send a correction, the conversation model can address the relevant running task with an ordinary tool call.
Persistent sessions: finishing isn't ending
The simplest of the three is persist=True, where completing the work doesn't end the session.
A normal persist=False action returns its result and is gone. The handle moves to completed_actions, where all you can still do is ask questions about what happened. A persistent action finishes a piece of work, surfaces its response upward, and then blocks, waiting:
# unify/common/_async_tool/loop.py — end of a turn, persist mode
if persist:
await _outer._notification_q.put(
{"type": "response", "content": _response_to_surface},
)
logger.info("Persist mode: waiting for next interjection...")
...
# Block until an interjection arrives or cancellation is requested
...
continue # Back to top of loop to process the interjection
return final_content # persist=False: DONEThe ConversationManager sees the response, marks the action awaiting_input, and keeps it in in_flight_actions, so the task is finished while the session is still alive. The full inner transcript is still in memory, and so is the Python sandbox with whatever state the work built up.
There is no separate "resume session" API. When you say "now do March", the outer model calls interject_<name>__<id>. The same loop wakes with the new instruction appended to its existing transcript. A follow-up and a mid-task correction therefore use the same mechanism.
Starting a new task with a summary is not equivalent. A summary can omit details that did not look important when it was written. It also cannot carry an authenticated client object held in a sandbox variable. A live session preserves both the transcript and that working state.

The cost is that keeping sessions alive becomes a real decision. Our system prompt pushes the outer model to default to persist=True whenever a follow-up is plausible, and to close sessions explicitly with stop_* rather than letting persist=False quietly throw away context we turn out to need.
Talking down: interjection
Interjections are how corrections get in. When the outer loop calls handle.interject(message), the message lands on the inner loop's queue and gets appended to its transcript as a user message, rather than as a system message or a synthetic tool result. The inner model sees it the way it sees any instruction, tagged so it knows it arrived mid-task.
It's also immediate. The inner loop runs with interrupt_llm_with_interjections enabled, so it races the in-flight LLM generation against the interjection queue. If your correction arrives while the model is mid-generation, the generation is cancelled and restarted with your message included. You're not waiting for the current step to finish before "no, wrong account" takes effect.

Talking up: clarification
The inner loop gets the mirror-image channel. An actor started with clarification enabled has request_clarification(question) in its tool surface. Calling it blocks that exact call site. The question travels up through the handle's clarification queue, the ConversationManager wakes and relays it to the user, and an answer_clarification_* tool appears for the pending question. When the answer comes back, it's routed down the same queues and the blocked call returns with the answer as its value.
So "which of these two Alices did you mean?" doesn't kill the task, it just suspends it at precisely the point of ambiguity and then resumes from that point with the answer in hand.

It nests
A large action may delegate to nested loops. Steering follows the work down, so pausing or correcting the outer action sends the same operation to its children. Each child records the change in its own transcript. A model several layers deep can therefore see that it was paused or redirected instead of encountering an unexplained change in context.

What persistence does not survive
Sessions are in-process. The handle and its sandbox live in the runtime's memory, so a persistent session survives across hours of conversation but not across a process restart. Durable state still has to be written somewhere real, and the actor does that explicitly. Python variables persist across execute_code calls only when stateful execution is requested; the default is a clean slate per call, which is usually what you want.
A live session also keeps its sandbox open. A runtime that never stops sessions will accumulate them, so the outer model receives an explicit stop_* tool for closing work it no longer needs.
The lifecycle policy still depends too heavily on the outer model remembering to close sessions. I expect that part to change.
Where to look
All of this is MIT-licensed and in the open at github.com/unifyai/unify. The pieces referenced here:
- Outer loop and action tracking:
unify/conversation_manager/conversation_manager.py actand the dynamic steering tools:unify/conversation_manager/domains/brain_action_tools.py,unify/conversation_manager/task_actions.py- The inner loop, persist wait, interjection and clarification plumbing:
unify/common/_async_tool/loop.py - The actor:
unify/actor/code_act_actor.py
The architecture doc has the deeper tour: ARCHITECTURE.md.


