Working in the Desktop App
This guide covers what you actually do in Portuni.app once it’s installed. For installation and first-run setup, see Desktop App.
The app has four views, switched from the left sidebar:
- Overview — a read-only, workspace-wide dashboard. Default landing view.
- Graph — the Cytoscape force-directed visualisation.
- Workspace (Work) — the node list on the left and, in the centre, the selected node’s detail or its task chat. Where most daily work happens.
- Settings — workspaces, account, runners, sync, MCP server section, and actors management.
The currently selected node lives in the URL as ?node=<id>, so deep-linking and copy-pasted URLs work across views.
Overview
Section titled “Overview”The default landing view: one aggregate, permission-filtered snapshot of the whole workspace (GET /overview), composed deterministically — no LLM involved. A strip of four counters on top — Waiting on me, Running, Needs attention, Not synced — each a shortcut to the place it counts (Work, Graph, the Not synced dialog). Under it, four cards; each shows at most eight rows and a “Show all N” link for the rest:
- Threads — your own inbox: running/suspended threads you started, ordered “Waiting on me” (an open question) first, then running, then suspended. A thread is its owner’s:
GET /overviewreturns your threads and nobody else’s, so the card and the counters above it never carry a teammate’s work, whatever your scope. Sessions opened by hand from a CLI are not rows here; the card’s footer says how many there are and how many run, and the node’s Threads tab lists them. Plus a headless review queue: nodes aheadlesssession reached only via search with no edge path (session_scope.added_via = 'disconnected') — see Scope Enforcement. - Needs attention — processes in
at_risk/broken, areas inneeds_attention, projects withhealth != on_track(see Lifecycle States), plus pending access requests (visible tomanagescope and above only) and stuck sync operations (pending_file_opsrows with a recordedlast_error— the closest server-visible signal to a sync issue; true file-conflict state is computed on-device and is not aggregated server-side; a personal workspace has no remote and lists no sync operations). - Recent activity — recent events and recent session writes (nodes added to a session’s write scope), interleaved by timestamp.
- New nodes — recently created nodes, human- and agent-created alike.
Every row is a link: clicking a node reference switches to Graph and selects it; clicking a session reference opens its node in Workspace and focuses that session. Nothing here is fetched automatically on an interval — use “Refresh”.
Graph view
Section titled “Graph view”The cytoscape view uses the fcose force-directed layout. Pan with the trackpad, zoom with pinch or scroll, click a node to focus it.
The sidebar carries four filter groups:
- Type —
organization,project,process,area,principle - Status —
active,completed,archived(archived is hidden by default) - Relation —
belongs_to,related_to,applies,informed_by - Organization — toggle each org on/off
There’s also a search box that filters by name (diacritics-folded). Hide everything except one org + one type to find a needle in a large graph.
When a node is selected, the detail pane on the right shows the same payload portuni_get_node returns — owner, responsibilities, data sources, tools, edges, recent events, files, local mirror path.
Workspace view
Section titled “Workspace view”┌──────────────┬──────────────────────────────┬──────────────┐│ Node list │ Task chat │ Node detail ││ (300 px) │ (the node's open session) │ (collapsible)││ │ │ ││ Sessions │ — or, with no session, the │ ││ under each │ node detail centre-stage │ ││ node, [+] │ and no right column │ │└──────────────┴──────────────────────────────┴──────────────┘- Sidebar header (every tab) — the workspace switcher under the brand, the Overview / Graph / Work toggle, “Search nodes…” (also
⌘K/Ctrl+K) opening a command palette that filters nodes by name, description and type (a longer result list is grouped by node type — Organization, Area, Project, Process, Principle — a short one shows the type beside each name; ↑↓ / Enter / Esc hints sit in the footer), and “New node”. The block is identical on every tab; in Graph a pick selects the node in the graph, in Work it opens the node. - Node list (left, “Open”) — two arrangements, switched by the Nodes | Status toggle (remembered per workspace). Nodes: every open node in the order you opened them, with a node-type dot and at most one activity dot (waiting on an answer or running, from live
session_stateframes — a suspended or new thread shows none); its threads (including an empty, just-opened draft — your own drafts follow you into another window of the same account as soon as that window refreshes the node’s threads; nobody else ever sees them) sit as sub-rows under the node name, the state in the tooltip — click one to jump straight to the chat, double-click to rename inline, and the×revealed on hover closes it (a draft is deleted outright; anything else is Close thread, without a dialog). Sessions opened by hand from a CLI are not threads; they stay on the node’s Threads tab. Exactly one row is marked: the thread the centre column is showing, or, when it shows the node itself, the node. A thread started anywhere else — the detail pane’s “New task”, the Threads tab’s “Continue from handoff”, another window — appears here as soon as its firstsession_stateframe arrives, without reopening the node. Hovering a node itself shows+(opens a new, empty thread on that node — the same one-click action as the detail pane’s “New task”) and×(close the node — its sessions keep running on the sidecar). Status: tasks only, grouped Need attention / Working / Suspended / New / Done, each with its node’s name underneath; a new, running or suspended task has the same hover×as in Nodes. - Centre — the task chat when the selected node has an open (running/suspended/draft) session; otherwise the same
DetailPanethe graph view uses, in “embedded” mode. - Node detail (right) — shown only while a chat occupies the centre, so the node stays visible next to its thread. The chevron at the top collapses it; the state persists in
localStorageunderportuni:workspace.detailVisible. A file opened from the Files tab replaces the detail with the editor in the same column.
Tasks run in the sidecar, not in this window: closing the window or the app never stops a run.
Detail pane interactions
Section titled “Detail pane interactions”The detail pane on the right is editable in both Graph and Workspace views:
- Identity row (under the name) — the node id (click to copy) and, for every node but an organization, either the local mirror’s path (left-truncated so the leaf folder stays readable, full path in the tooltip, click to copy) with a Finder button next to it, or — when this device has no mirror for the node yet — a button to create one. A remote-folder action follows either way: a Google Drive icon (or a generic link icon for a non-Drive remote) to copy its sharing link, plus a button to open it. There is no lifecycle/status badge here any more; project health still shows in its own place (see Lifecycle States).
- Edit fields — name, description and goal have their own
Penciltoggle and commit onSave; lifecycle state and owner save immediately on selection. - Sharing — the visibility selector (Everyone / Private / Group). A node that inherits a group ACL from its organization (or nearest restricted ancestor) shows a single read-only summary (“Inherits sharing from …”, the effective mode, the inherited recipients) with one action, “Set own sharing for this node”; that opens a card prefilled from the inherited list, and nothing is sent to the server until “Save” (“Cancel” discards it). Once a node has its own list — its own override, or a group set directly on a node with no restricted ancestor — the same card autosaves every change immediately, with a brief “Saved” readout, plus a “Remove own sharing and inherit from the organization” action to drop the override. The two destructive cases — switching away from a group with existing grants, or removing its last recipient — ask for an inline confirmation first. A group with no recipients yet is the only unsaved autosave state; it persists once the first recipient is added.
- Responsibilities — add, edit, reorder (drag), delete; assign actors per row.
- Data sources & tools — add/remove with name + optional URL.
- Edges — outgoing and incoming, with a
→/←indicator. Click an edge target to navigate to it (updates?node=). - Files — list of tracked files with
remote_pathand the derivedlocal_pathfor this device, plus a sync button (team workspaces only — a personal workspace has no remote at all, see Files: the two sync planes) that mounts as soon as the node has a mirror, even with nothing tracked yet. It’s never disabled: with something to push/pull it reads “Sync (3 files to sync)”; once nothing is locally pending it reads “Check the remote” instead of claiming the node is done — a run’s remote sweep is the only way to notice a file that showed up on Drive out of band, so a freshly created, still-empty mirror needs the same button as a busy one. Open in Finder, copy path, or delete (confirm-first). The tree groups the files under three sections —wip(“in progress”),outputs(“outputs”) andresources(“resources”). A section is a group heading, not a folder: it shows the count and its one-word description, carries the sync dot of everything inside it, collapses with its chevron and has no actions of its own — it cannot be renamed or moved. Folders under a section are ordinary rows in the same face and size as the file rows (chevron, folder icon, name, count, sync dot, and a hover action strip with “Rename” and “New subfolder” at the right), with a guide line down the left of their children; a top-level folder outside the three sections renders as such a row too. Opening a file swaps the editor into this same right column in place of the node detail — a “← Back” button returns to it. Every file but a Showtime deck offers a Preview | Edit toggle: Markdown and HTML open in Preview (rendered preview) by default, anything else opens in Edit (source editor); switch either way any time. “Save” (Cmd/Ctrl+S) shows up only in Edit, and only once the file is dirty, sitting left of the toggle so its appearing/disappearing never shifts the other buttons. A ⤢ next to it expands the editor to a fullscreen overlay with the same controls (mode toggle, Save, plus ⤡ to collapse back and × to close). A.showtimedeck (a Showtime bundle) opens as the rendered preview the bundle carries once the Showtime integration is on (Settings → Integrations); the preview is read-only and offers “Open in Showtime” when Showtime.app is installed. That button hands Showtime the deck and the node: the agent Showtime starts beside the deck connects to the Portuni MCP server with this node as its home (so it appears under the node’s Threads tab like a task started from Portuni) and gets the node’s mirror as a second working directory. The bearer never travels in the link; Portuni mints a one-time code and Showtime exchanges it over loopback. A refused handoff shows its reason in the preview bar and opens nothing; a Showtime without theshowtime://deep link asks you to update Showtime. With the integration on and Showtime.app found, “+ New file” is a split button: the chevron offers “New file” and “New presentation”. The latter hands Showtime the node’swip/and a one-time code (showtime://new); Showtime’s New Deck screen opens with that folder fixed and the node named, you pick the design system, template and name there, and the bundle it writes shows up under Files through the mirror watcher — the agent beside it is a session on this node, as for “Open in Showtime”. Disabled (with the reason) on a node without a mirror on this device: Showtime writes to disk, so there is nowhere to put the deck. Which agent runs beside the deck is Showtime’s own setting, not Portuni’s agent preset. A bundle saved by a Showtime older than the bundled preview says so instead of rendering. - Events — recent timeline; resolve / supersede inline.
- Threads — persistent sessions anchored to this node (
GET /nodes/:id/sessions; states and transitions at a glance: Thread Lifecycle), newest-active first: state (running/suspended/closed/archived, archived hidden behind a “Show archived” filter), last activity, CLI + instance (CLI is read from the MCP handshake itself, not a header — populated for Claude Code, Codex and Mistral Vibe alike), the host that ran it (the machine whose sidecar started the latest run — this device’s name when the task ran here — the app asks this device for its own name, since the central server of a team workspace cannot know it — and its id when the task ran on a teammate’s device; omitted when no run claimed one), therunnerwhen the session was started as a task,waiting_sincewhen its run is blocked on a question, and write count (size of the session’s write scope, which always includes the session’s own home node — a session that only ever wrote there still reports 1, not 0). Name defaults to<node> · <date> <time>(the time component keeps two same-day sessions on the same node distinguishable) and is enriched from the summary’s title when Hand off writes one, but is always renamable inline. Asessionsrow is only ever created once a connection completes its MCP handshake — a client’s protocol probe, an aborted connection, or any other non-initializefirst request never leaves a row behind. Arunningrow is never simply dropped: a run that ends for any reason other than Close thread —PORTUNI_RUN_IDLE_MS(default 30 min) of inactivity with no turn in flight, a provider limit (the runner’s own spend/rate limit, whose message lands in the transcript as a provider error), an error, or a startup sweep finding a row from a process that no longer exists (crash, restart) — suspends it instead, and writes no summary: the next message continues the CLI conversation, or, where that conversation cannot be resumed, starts from a summary built at that moment from the transcript on the device that ran the thread. A summary file (wip/sessions/<id>-handoff.md) is written only when you ask for one — Hand off and Continue in a new thread — and always by the device, which holds the transcript; the central server never writes one.closedis reached only by explicitly clicking Close thread (or Close on its row), andarchivedonly by the auto-archive sweep of old closed sessions; writing into a closed thread reopens it. The thread’s own MCP connection closing is not one of those reasons: an agent that spends half an hour on files without calling a single Portuni tool — or whose connection simply drops — keeps its thread, its run and its transcript, and its next connection binds back to the same thread. A dropped connection does still suspend a hand-opened CLI, whose session was that connection and nothing else, with no summary. A suspended row shows whether the underlying CLI conversation is still resumable or will fall back to the summary, and links to the handoff file when Hand off wrote one (or, when this device had no local mirror for the node then, the handoff text is still resumable from — it was simply never written to a file here) — this is informational only now: there is no separate resume action, sending the next message into the thread is what resumes it,--resumeon the last run when still valid, from the summary otherwise. Each row’s status dot doubles as a chip (Running / Waiting on me / Suspended / Done / Archived, “Waiting on me” overriding “Running” while a question is open). A row names the thread and never quotes it: what was said is content, and content is not on the central server to be listed. The tab lists your threads on this node and nobody else’s — a thread is its owner’s, so seeing the node (or holdingmanage) says nothing about the work other people started on it, and there is no owner column to show. “Open chat” jumps to the task chat — on a closed row too: the chat shows the thread’s history with the composer open, and your next message reopens it (there is no separate resume action any more; Continue in a new thread is the deliberate fresh start). A closed or archived row offers Continue in a new thread too: the thread keeps its state — an archived one stays archived — and a new thread on the node starts from its summary, which Work then shows; it needs this device to hold the transcript, and a hand-opened CLI session, which has no runner to continue under, does not get it. Close on a row asks nothing. Every action here is offered on state alone, because every row is yours. Above the rows the tab lists the node’s Handoffs to continue from: everywip/sessions/<id>-handoff.mdfile tracked on the node, whoever wrote it — including one that arrived here by sync from another machine. Such a file names the thread it came from (the thread’s name, its device and its last activity when that thread is one of yours; otherwise just the file name, because a thread is its owner’s and a file says nothing about who may see the record), opens in the editor like any other file, and offers Continue from handoff:POST /sessionswith the file’s path, which starts a new thread here from what the file says and switches Work to it. The file has to be on this device already — a handoff that has not synced here yet is refused (HANDOFF_FILE_NOT_HERE) and nothing is created.
Arranging files in the Files panel. Files are dragged between a node’s
folders and sections with the mouse, but no drag takes effect right away: it
builds up a plan, which is applied only with the “Apply” button. You can
drag a registered file inside the wip, outputs and resources sections,
and a whole folder (every file inside moves with it). A row that cannot be
dragged says why in its tooltip — the file is not registered yet (the
watcher registers it within moments), it lies outside the three sections, or
the node has no mirror on this computer. The target is a folder, a section,
or a file row (which targets the folder that file lies in); a collapsed
folder expands on its own once the cursor rests on it for a moment. A target
that already holds a file of the same name, and a folder dragged into
itself, are refused right on drop, with the reason in the tooltip.
A planned file gets an accent stripe at the left edge of its row, its
original folder struck through and a “move” badge; above the tree a bar
appears reading “3 changes waiting to be applied” with “Discard” and
“Apply” buttons. The bar is there whenever the plan holds anything — both a
planned move and a new (virtual) folder count as changes, so even a plan
holding only a new folder can be dropped with “Discard”. “Discard” drops
the whole plan, new folders included, and nothing happens anywhere.
“Apply” goes through the planned moves one by one — a plan without a
single move has nothing to apply and the button is disabled: a file that is
already on the remote is only renamed on Drive (its content is not uploaded
again), a file that has never been pushed changes only its record and its
copy in the mirror and stays in the push state at the new path. When a
move fails, the batch stops — what is done stays done, the failed row shows
the reason and the rest of the changes stay in the plan for “Apply again”.
The plan belongs to the node on this computer, survives leaving the node
and restarting the app, and is sent nowhere.
Folders in the Files panel. Next to “New file” is “New folder”: it
opens a form with the path prefilled with a section (wip/); Enter or
“Create” creates the folder, Escape or “Cancel” closes the form. An invalid
path (outside the three sections, with .., with an empty segment or with a
leading or trailing space), and a path another folder already has — real or
planned — is refused with the reason below the form. On a node with no
mirror on this computer the button is disabled, with the reason in its
tooltip. On hover, a folder row offers the same form under the “New
subfolder” action (the path is prefilled with that folder) and a “Rename”
action: an inline input with the current name, Enter plans the rename,
Escape cancels it, a refusal shows below the row. The wip, outputs and
resources sections have no such actions — they are group headings, not
folders.
Until “Apply”, a new folder is only virtual: a row in the tree with a dashed icon, a zero count and a “new” badge; nothing has been created on disk or on the remote. It becomes real only when the first file ends up in it once the plan is applied; when the last planned file leaves it again before that, it disappears from the plan too. Renaming a real folder means planning a move of every file inside it (the folder itself has no record) — they all get the “move” badge, the folder shows in the tree under its new name and the old one disappears; for a virtual folder only its row is rewritten. A folder holding an unregistered file cannot be renamed, so that it is never left only half moved; the watcher registers the file within moments.
The action bar below the pane (non-organization nodes) is “New task” — see Task chat.
Every mutating action calls back through onMutate which refetches the graph and the node detail, so the rest of the UI stays consistent.
Task chat
Section titled “Task chat”“New task” on a node’s detail pane (or the “+” on its sub-row in the
left column) opens a thread immediately — one click, no dialog: POST /sessions with just { node_id } creates a draft session (a name,
a node, an owner, nothing else yet) and Work’s centre column switches to
SessionChat for it right away, composer focused. There is no runner or
instance to pick up front. The first message you send
(POST /sessions/:id/messages) is what promotes the draft to running
and starts its first run: the server picks the first installed &&
logged-in runner (GET /runners) and, when the node’s organization has a
default instance registered for it (Settings → Runners), that instance —
the same rule a manual picker used to apply, just resolved server-side
instead of asked up front. The thread’s name comes from that same first
message (first line, trimmed, cut at ~60 characters), not from the node
and date the way an ownerless chat session still defaults to. A draft
closed before a first message is sent is deleted outright, with no
question, from any of the places that close a thread: the × in either
sidebar arrangement, Close thread in the chat header, or Close on its row in
the node’s Threads tab; and one left
open longer than 24 hours is pruned by the same server sweep that resolves
a running row orphaned by a crashed process.
The chat reaches its conversation over the window’s live channel (see
below). When that channel stays disconnected for more than about three
seconds — at start-up before the backend is up, or after it drops — the
chat says so in place of “Loading the conversation…”, with the reason and
a Reconnect button, and the footer shows a live… pill (live × once
the channel is closed) that reconnects on click. The app keeps retrying on
its own with a backoff of 1 to 30 seconds; once the channel opens, the
conversation loads without a restart.
Starting a run takes a moment — the runner’s process has to come up — and anything you do in that moment waits for it rather than racing it. A second message sent while the thread is still starting is delivered to the run that start produces, as an ordinary next message; the same holds for a message sent into a thread you have just woken up by writing into it. Close thread and Hand off wait for the start too and then act on the run it produced, so a thread can never end up with two runners’ processes on it, and closing one never leaves a process behind on a closed thread.
A message written at the moment a run is ending is not lost either. A run can end while you are typing — the runner hit its spend or rate limit, it errored, the thread had been idle long enough to be suspended, or you pressed Hand off. The message still lands in the transcript, and Portuni delivers it: it waits for that run to finish ending and for the thread to be suspended, then wakes the thread with that message as the first message of the next run. You see it once, the agent answers it, and nothing has to be typed again.
A message is either delivered once or reported as not sent, never both. When the connection to the sidecar is down as you send (the laptop slept, the sidecar restarted) and does not come back within 30 seconds, the chat says the message was not sent and keeps the text in the composer; that message is then dropped and never delivered later, so sending it again reaches the agent once. A message that already went out waits for its answer however long the start takes. When the connection drops while a message is on its way, the chat reports it at once; that message may have reached the agent, so check the transcript before sending it again.
When the model’s API fails (the model is unavailable, overloaded, the prompt is too long, or a limit was hit), the error shows in the chat once, as an error and never also as the agent’s reply, and the context ring keeps the last size the conversation really had.
The inactivity timeout never catches a thread you have just gone back to. It looks over all your live threads at once, but suspending one takes a few seconds, and each remaining thread is checked again the moment before it is suspended: one you wrote into, answered a question in, or that the agent started working in again in the meantime keeps running and waits for the next round.
Every thread you have open in the window stays live while you look at another one: each one keeps its own chat, and switching is only a change of which one is on screen. You come back to the same scroll position in the transcript, the same half-written message in the composer, and a transcript that kept streaming while you were away — nothing is fetched or replayed again. A thread’s chat is dropped only when you close the thread or close its node in the left column.
SessionChat’s header shows the session name, a status chip derived from
state and waiting_since (“Running”, “Waiting on me” when a question is
open, “Suspended”, “Closed”, “Archived”), and the runner/instance.
Stopping a turn is not a header action any more: the composer’s own
submit button doubles as a stop control (a stop square) while a turn is
in flight, and Esc does the same — both just call interrupt(), which
cancels whatever the model is doing right now without ending the run, so
you can keep typing straight after. A stop while the agent waits on your
approval closes that question too: the thread stops waiting on you and the
agent’s next question shows straight away. Once a turn has stopped, nothing
in the transcript still looks like it is working, and a half-written answer
is dropped rather than prepended to the next one. A message you write while the agent
is still working queues behind the turn in flight and counts as work of
its own: the stop control, the working row and the idle countdown all go
by how many of the messages you sent are still unanswered, not by the
first “turn finished” that comes back. Once the agent has answered
everything you sent, the run stays alive only to take your next message:
the button is a plain send again and nothing is shown as working. The remaining header actions are
Hand off (see below), Continue in a new thread (on a running,
suspended or closed thread whose transcript is on this device —
POST /sessions/:id/continue, which closes a running or suspended
thread and leaves a closed one closed, writes its summary to
wip/sessions/<id>-handoff.md in the node’s mirror when this device has
one, seeds a new thread with that summary, and switches Work to it; on a
device that does not hold the transcript it refuses and names the device
that does) and
Close thread, which closes the thread without asking: it ends the run
(the same close interrupt never does) and moves the thread to Done.
Closing is not the end of it — a closed thread opens from the Threads tab
(“Open chat”) with its whole history, and writing into it reopens it
exactly the way writing into a suspended thread resumes it (the same
conversation when it still exists, otherwise a summary of the transcript).
Only an unfinished turn is lost, the same as with Stop. All
follow the access table below; a refused action surfaces the server’s own
error, there is no client-side prediction of who may do what.
Where a thread lives: the central server holds the record — that the thread exists, on which node, whose it is, its state, runner, model, its runs and its write scope — and the device that ran it holds the content: the first message, every event of the transcript, the inline handoff summary. Content is never sent to the central server, so nothing you say in a thread is stored outside the machine you said it on, and there is no backup of transcripts: losing a device’s database loses its conversations, while the records on the central server and the handoff files synced into the nodes remain.
The consequence you see: open one of your threads on a second device
and the header, the state and the actions are all there, but the
conversation is not. The chat says “The transcript is on the device X” with
the name of the machine that has it, the composer is disabled, and the way
to pick the work up here is the round trip below — Hand off on X, then
Continue from handoff here. Hand off itself is hidden on such a thread: the
summary is written from the transcript, and this machine holds none of it.
A message sent into such a thread through REST is refused the same way
(409 HANDOFF_TRANSCRIPT_ELSEWHERE, or HANDOFF_NO_CONTENT when the
record names no other device but this one holds no content either)
instead of waking an agent that knows nothing of the thread.
“The device” is the Portuni installation, not the machine’s name: each workspace keeps a device id of its own in its data folder, created the first time it starts, and the machine’s name is only the label shown next to a thread. Renaming the computer keeps its threads its own — Hand off and Continue in a new thread work on them as before, and the thread shows the new name, in a team workspace as in a personal one (the app reads the name from this device, not from the central server). A thread started before this id existed is taken over the first time the app starts after the update.
Hand off (the button reads “Hand off to another device”) hands the thread to another machine. It is offered on a running
or a suspended thread, in the chat header and on the thread’s row in the
Work sidebar. On a running thread it ends the turn and the run, and the
device writes the thread’s summary to wip/sessions/<id>-handoff.md in the
node’s mirror — a tracked file of the node like any other, so the next sync
carries it — and the thread goes to “Suspended”; the chat then names the
file it wrote. Pressing it again on the same thread changes nothing and
answers the same path. A thread that was suspended on its own (idle, an
error or a limit, the app quitting or restarting) has no file: Portuni
writes a handoff file only when you ask for one, with Hand off or
Continue in a new thread, and the chat shows “Summary saved” only
then. On such a suspended thread Hand off writes the file now, from the
transcript this device holds. Hand off refuses, and says why, before
it touches anything: on a draft (nothing to summarise) or a closed thread,
on a node that has no mirror on this device (there is nowhere to write the
file; the running thread keeps running), on a thread whose run is live on
another device, and on a thread whose transcript is on another device —
those two name the device, and that is where to press it. The transcript
never travels — only the summary file does — so the other machine
continues from what the file says, not from the conversation.
Continue from handoff is how the other machine picks it up. The node’s Threads tab there lists the node’s handoff files as soon as the sync has carried them; choosing one starts a new thread on that machine — a new name (the summary’s own title), this machine’s runner and device, and a first run that reads the summary as its orientation. Nothing is imported from the old thread: its transcript stays on the machine that wrote it, its record keeps its own state, and the new thread’s conversation starts empty. So the round trip is: Hand off here, sync, Continue from handoff there — and the same in reverse when the work comes back.
The event list renders the session’s canonical log, delivered entirely
over the live WebSocket below (a subscribe replays the persisted log,
then streams what follows): user and assistant messages as chat bubbles,
reasoning as a collapsible aside, tool_call collapsed from its started
and completed/failed pair into one row showing the title (click it to
expand the input summary and output excerpt), file_change linking into
the Files tab, and compaction, handoff, state_changed and
run_ended as centered system markers. What the runner itself says in
the chat — a question card’s title, its own error, why it denied a tool
call — is stored as a code and shown in your language; what the agent, a
tool or the provider wrote is shown exactly as it came, never translated.
Both assistant text and reasoning
stream in from delta frames — a channel: "text" delta builds up
towards the next assistant_message event, a channel: "reasoning" one
towards the next reasoning event — and each buffer clears once its own
matching event lands; the reasoning aside stays open while its deltas are
still arriving and collapses once the batched event replaces it. A
question panel appears above the composer while
waiting_since is set — option buttons for an approval, a text field for
free-form input; an agent’s question with a choice (AskUserQuestion) shows
each question’s options as buttons above that field, one question under
another when it asks several. A single one-choice question answers on the
click; otherwise pick an option for each (a multi-select question takes
several) and send with Send, where whatever you type answers the
questions you left without a pick. An empty field sends nothing, and the
answer goes out once however often you click — also for a confirmation dialog an MCP server raises
mid-tool-call, such as Portuni’s scope expansion or write access (Yes
accepts, No declines; Stop closes it; a dialog asking for more than a
yes/no is declined, and one asking you to sign in through a browser link is
declined with a line in the transcript naming the server).
The run uses only its own Portuni connection: a claude.ai connector its
profile inherits that points at this Portuni’s server (recognised by its
URL, whatever it is named) is switched off for the run, because that
one’s dialogs would go to claude.ai. The composer itself disables while closed or
archived, or when you are not the session’s owner (messages and answers
are owner-only; anyone who can see the node can read along). Unlike
before, it does not disable while suspended: a suspended thread shows
a dismissible notice above the composer instead (“the process was ended;
the next message replays the whole conversation into the model”) and
stays fully usable — sending is exactly what resumes it, --resume on
the last run’s conversation while its transcript is still on this device
(looked for under the thread’s instance CLAUDE_CONFIG_DIR, ~/.claude
when the instance names none), otherwise from a
summary: the handoff file Hand off wrote, or else one built from this
device’s transcript at that moment; the server decides, there is no mode
picker any more.
Dismissing the notice only hides that one instance; the next time the
thread ends up here (a new run starts, then also ends other than by
Close thread) shows a fresh one. The header names the runner, instance and
host — the machine whose sidecar last ran the thread, shown by its label
when Portuni can name it and by its id otherwise, and left out entirely for
a session no run ever claimed — plus the thread’s own model/reasoning-effort
override when it has one (see
Runners: model and reasoning effort).
The composer has two rows under the text. The first holds the model
selector (GET /runners/:runner/models — documented aliases until this
device has run a task, the real list after) and, only when the chosen
model supports it, a reasoning-effort selector labelled as applying from
the next run, not the current one. A thread without its own choice shows
the default it will run on, before the first message: “Default: Opus 5.5”
and “Effort: medium (default)”; hover either to see where the value comes
from (the runner instance, an environment variable, your Claude Code
settings file, or the account’s and model’s own default). The second, dimmer row says where the
thread runs: runner and instance, chosen from a list of every logged-in
runner and its instances while the thread is still new (the organisation’s
default is preselected and marked “(default)”; the choice is fixed once
the first message goes out), and the host. The header shows the name, the
state and, once the run has reported, a context ring with the share of the
model’s window in use (the main agent’s context only: a subagent the
agent starts has a window of its own and never moves the ring); from 80 % it turns amber and “Continue in a new
thread” becomes the primary button. While the agent works on a turn,
the transcript always shows what is happening: streaming text, the tool
that is running, or a “Starting… / Thinking… / Continuing…” line with a
counter; between turns it shows nothing. A subagent’s own messages stay
out of the transcript; its tool calls show nested under the agent’s call
that started it. Tool calls and reasoning fold into one line per turn (“Read
3 files · 2 commands”); expand it to see each call. While a run
is live the header also shows the restart indicator (run age, write/read-set
size, scope expansions since the run started) as plain information — no
action of its own; Continue in a new thread above is what carries the
work into a fresh context when it’s needed.
A session can also be driven directly over REST, ahead of or instead of
the chat UI above: the same POST /sessions plus POST /sessions/:id/messages (send a chat message — starts a fresh run first
if the last one ended, Close thread included, --resume or from the
summary, the server’s own choice; only an archived thread refuses it), POST /sessions/:id/questions/:request_id (answer an open question), POST /sessions/:id/interrupt (cancels the current turn only — the run stays
live), POST /sessions/:id/continue (closes a running or suspended
session and leaves a closed or archived one as it is, writes its
summary as its handoff file when the node has a mirror here, seeds a new
one on the same node from it, returns { session, run } for the new one;
refused with SESSION_TRANSCRIPT_ELSEWHERE or HANDOFF_NO_CONTENT when
the transcript is not on this device, and with SESSION_STATE_CHANGED
when the body’s optional expect_state no longer matches the thread’s
state — the Threads-tab row sends the state it showed, so a click on a
row that another window reopened meanwhile ends nothing), POST /sessions/:id/close, GET /sessions/:id/signals,
GET /sessions/:id/scope (the session’s read and write set by node id,
the same two sets the written summary lists), and
GET /sessions/:id/events?after&limit for the canonical event log the
chat view above renders from. POST /sessions/:id/suspend and POST /sessions/:id/resume are gone — there is no separate suspend handshake
any more, and resuming is just sending a message.
POST /sessions, POST /sessions/:id/messages, POST /sessions/:id/handoff (Hand off) and POST /sessions/:id/continue — and the
live channel’s message, continue and handoff frames — accept an
optional locale ("en" or "cs"): the language of the text Portuni
writes for you as a result, such as the handoff file and the default
thread name. The app sends its current interface language, which you pick
in Settings → Account (stored on your account, PATCH /me with { locale },
so it follows you to every device). A request without locale gets
English. The handoff file’s headings, labels and the last-activity date
(shown in UTC) are in that language; messages, file paths and node names
are copied as they are.
Who may call what follows one access table across every task route: read
(GET /sessions/:id/events) is anyone who can see the session’s anchor
node; message (send a message, answer a question, rename) and
resume (continue) are the owner only; stop (interrupt/close) is
the owner or anyone with manage scope. A session anchored to a node you
cannot see reads as a plain 404, same as the node itself being invisible;
a node-less session (a plain interactive chat, not a task) is 404 for
everyone but the owner. An interrupt/close by someone other than
the owner is recorded in the event log with who did it.
A task’s own MCP connection (the one the runner spawns to talk back to
Portuni) binds to the session POST /sessions already created instead of
minting a second row — this is why a task’s Threads row appears the moment
you start it, not only once its first tool call lands.
It authenticates with the same token the sidecar’s front door checks
(PORTUNI_AUTH_TOKEN, which the app gives every workspace’s sidecar), in a
personal and a team workspace alike, so the chat has Portuni’s tools
(mcp__portuni__*) from its first turn. A sidecar never runs without that
token – it refuses to start at all – so a chat never connects with an
empty bearer. A run that cannot be set up creates nothing: no thread, no
first message, a draft stays a draft and the thread you wanted to continue
stays as it was.
Live channel: GET /sessions/ws
Section titled “Live channel: GET /sessions/ws”The REST task routes above are for scripts and tests; the desktop window
itself talks to one WebSocket, GET /sessions/ws (upgrade, same bearer/
JWT auth as every other route — an upgrade that fails auth is refused with
401 and the socket is closed; a plain GET without an Upgrade header
answers 426). The socket is mounted in both kinds of workspace: on the standalone
/ local sidecar over its own database, and on the sync agent
over the same runtime its REST task routes drive, so the window’s
connection always targets its own sidecar. Opening the socket needs
read scope; message, answer, interrupt, continue and close
frames need write scope (FORBIDDEN otherwise) and, in the packaged
app, an upgrade that carried the webview-proxy proof described above
(WEBVIEW_PROXY_REQUIRED otherwise) — a subscribe works either way. Every frame is JSON { id?, type, payload }; a frame
carrying id gets { id, type: "reply", payload } on success or
{ id, type: "error", payload: { code, message, params? } } on failure — the same
codes the REST routes answer with (SESSION_NOT_FOUND, NO_LIVE_RUN, NO_PENDING_QUESTION, …). A refused
action is always an error frame, never a closed socket.
Client → server: subscribe { session_id, after } (replays the persisted
event log after after, then streams live), unsubscribe { session_id },
message { session_id, text }, answer { session_id, request_id, decision },
interrupt | close { session_id }, continue { session_id } (replies with
{ session, run } for the new session so the client can switch to it
without a second round trip) — each mapped to the same runtime call and
access tier the REST route uses.
Server → client: event { session_id, event } — a persisted canonical
event, carrying the seq the store assigned it; events { session_id, events } — a page of up to 200 of them, in seq order, which is how a
subscribe replays the log; delta { session_id, run_id, channel, text } — streamed text, never persisted, never replayed,
channel one of "text" | "reasoning" saying which persisted event this
delta is a live preview of; and
session_states { sessions: [...] } — one frame the moment you connect,
listing every running or suspended session you can see (newest activity
first, at most 500); and session_state { session_id, state, waiting_since, node_id } — one session, sent on every state_changed, question or
run_ended anywhere, with no subscription needed. A thread the runtime
suspends on its own (idle, an error or limit, the process ending) sends one
more state_changed once it is suspended, so the last frame always says
suspended. This is what lets the Threads tab, the Work sidebar and Overview
update live instead of polling. A frame for a node open in Work also refetches that node’s thread
list (GET /nodes/:id/sessions), which is how a thread started outside the sidebar shows up there;
the refetch is coalesced per node, so a burst of frames costs at most two round trips.
Reconnect rule: a client that drops and reconnects re-subscribes to each
session it cares about with the last seq it actually saw — nothing is
lost, because events are the durable record and deltas were always
disposable.
Settings
Section titled “Settings”Sections worth highlighting:
- Theme — light / dark; the choice persists in
localStorageand is reapplied on launch. - Account → Language — pick English or Čeština. The choice is saved to your account (
users.locale,PATCH /me), so it applies on all your devices; English is the default. - MCP server — shows the sidecar’s URL (typically
http://localhost:4011/mcp), port, and whether an auth token is set. The bearer token itself lives in macOS Keychain (Tauri-only); reveal it on demand or rotate with one click. Rotating restarts that workspace’s sidecar with the new token, so the app keeps working without a restart; external clients need the new token (re-run the install buttons, re-export it in your shell). The install buttons write the URL + token into~/.claude.json,~/.codex/config.toml, and~/.vibe/config.tomlso external clients can talk to the app’s sidecar without manual config editing. - Sync — informational: the server URL plus the remote watcher’s and the mirror watcher’s current state. Team workspaces only; a personal workspace has no remote and no Sync tab. See below.
- Integrations → Showtime — off by default, stored in
localStoragelike the other settings. On, a.showtimedeck in a node’s files opens as a rendered preview (thepreview.htmlShowtime packs into the bundle at every save;GET /nodes/:id/filereturns that entry astext/htmlfor a.showtimepath) and the preview offers “Open in Showtime” when Showtime.app is installed — the section shows whether Showtime.app was found (/Applicationsor~/Applications) and what the button hands over: the node’s Portuni connection for the agent and the node’s mirror as a working directory. It also puts “New presentation” behind “+ New file” on the Files tab (see Files above). Off, the bundle is a binary file like any other.
Collaboration in Portuni is team workspace — a local (single-machine) workspace has no remote to connect at all, so it has no Settings → Sync tab and shows nothing else about syncing either (see Mirrors): stored files always sit in the local mirror only, and a node’s Files pane shows a “Files are stored on this computer only and are not shared. …” banner. A central workspace shows the server URL that manages file sync — nothing to configure client-side either. Configuring the actual Google Drive remote (a Service Account on the central server) is an MCP-only, one-time admin task; see Setting Up Remotes.
The tab does report one live thing, though: a remote watcher line per
remote. “backoff_until) means that polling is currently
failing — Drive changes are not being applied live, and the fallback is
the periodic full sweep every 6 hours. “
Recommended daily flow
Section titled “Recommended daily flow”- Open
Portuni.app. Workspace view. - Pick the node you’re working on from the left list (or jump from the graph view).
- “New task”: opens an empty thread right away with the organisation’s default runner and instance preselected in the composer; change them there if you want to. Write the first message and the run starts.
- Work. The agent uses Portuni MCP tools (
get_node,get_context,log,store, etc.) via the embedded sidecar — same surface external clients see. - When done,
portuni_status(or rely on the agent to call it) before ending the session so disk / DB / remote stay consistent — this rule is enforced by the server-level instructions.
See also
Section titled “See also”- Desktop App — install, first run, update flow
- Symbiotic Workflows — how the agent and the human share the graph
- Local Mirrors — the per-device mirror model the workspace view surfaces