OpenOv AI

How it works

One instance is one directory. In it: a server that hosts every session on one page, one Claude Code process per seat, a set of tools those processes reach over a secret of their own, a desk per person, and a directory of notes every session reads. Five minutes, before any command.

One instance, one directory

An instance is a directory of its own, and nothing else ever lands in it. It carries its own copy of the toolkit, its own Claude Code home — account, transcripts, settings — and no absolute path, so it can be moved and still be itself. Two instances on one machine share neither account nor transcripts.

<instance>/
  bin/ovai          the one command
  lib/              everything else the release ships; replaced whole on update
  openovai.json     the instance's description of itself: who works here, on which models, which port
  runtime.json      the running server: url, pid, since
  runtime.log       what the server said, one row per line
  plugins/          tools the instance serves itself, one file each; yours
  knowledge/        what the workspace knows: one Markdown note per topic
  customization/    what you add to the personas; yours, never touched
  desks/<Name>/     one directory per person: STATE.md, persona.md, conversation.json
  archive/          retired desks, moved whole
  reference/        kept to look at; never worked on
  projects/         worked on
  temp/             scratch, deletable by anyone at any time
  .claude/          Claude Code's project settings for the instance
  .local/           Claude Code's config dir for the instance: account, transcripts

In groups: the product is bin/ and lib/; the instance's own is openovai.json, the runtime files, plugins/, knowledge/ and customization/; the people are desks/ and archive/; your material is reference/, projects/ and temp/; Claude Code's is .claude/ and .local/.

the parts, and who talks to whom
  you ──── browser page ══ one event stream ══ server (Node, 127.0.0.1:port)
                                                  │  the only writer of every session's stdin
                                                  │  serves the tools at POST /mcp/<secret>
                                                  │  writes runtime.log
                                   ┌──────────────┼──────────────┐
                              Leader          Worker           Worker      one Claude Code process per seat
                              desks/Max/      desks/Paul/      desks/Jane/ one desk per person, on disk
                                   └──────────────┼──────────────┘
                                          knowledge/                      what outlives every desk
                                          plugins/                        tools of your own

The server

ovai start starts it in the background and prints the address it is listening on: the instance's page, on the port it was installed with, on 127.0.0.1 only. The terminal is yours again the moment the address is printed. The server starts the seats, is the only thing that writes to their stdin, serves the page from one event stream, serves the tools, and writes the log. If the port is already taken it says which process holds it, with the pid; a server already running is reported with its address and left alone. Typed in a session's own shell, it hands that session on to nothing: the server and every seat it starts drop what Claude Code put on the session — its id, pid, entry point, effort, messaging socket and token — and the shell's PWD and OLDPWD.

Every row of runtime.log has the same four columns — the moment, the event, the seat, the tool-call id — then the text. There are no levels: the event is the level. The three rows of one tool call share its id, so one grep is a call's whole life. No request is ever logged. The log says what became of the sessions as well as what they did: a Worker hired and what it runs on, a desk filed away and where it went, a restart with how full the session that restarted was, a turn the service refused in the service's own words, a close that reached its deadline with no desk written, and every time the account's usage crosses a threshold.

2026-09-20T02:15:33.123+02:00 started - - serving /home/ana/workspace at http://127.0.0.1:7357 pid 41200
2026-09-20T02:15:41.008+02:00 called Max toolu_01GFHRkEQuMCjA5AvqQUSdHM Read /home/ana/workspace/desks/Max/STATE.md
2026-09-20T02:15:41.530+02:00 called Max toolu_01HMVftEemFhWAccN3xSqn7a mcp__openovai__write_desk
2026-09-20T02:15:41.532+02:00 tool Max toolu_01HMVftEemFhWAccN3xSqn7a mcp__openovai__write_desk in 1 ms
2026-09-20T02:15:44.210+02:00 queued Paul - message #54, 1 waiting
2026-09-20T03:02:10.884+02:00 stopped Paul - idle

ovai stop parks the room first — every Worker is closed and the Leader told to write its desk and stop, with a deadline — then closes the server. It waits for the sessions and nothing else: anything else a seat left running is named by the seat that left it, with one line that ends all of them. Stopping what is not running says not running and is not a failure.

The page and its panels

Three columns: the Leader's panel in the middle, the Workers' panels either side, alternating left and right in the order they appeared so a panel keeps its side. At 900 px and below it is one column, the Leader first. Nobody has a terminal of their own; the Leader and every Worker get the same panel, with the same text box under it, and you can type on any of them.

the Leader's head: model, context, version and the usage windows, then the theme, notification, messages, status and auto mode switches

A panel is a head — a dot, the name, the model, the context in k once known, and on a Worker what its session is doing: listening, working, waiting for you, with the number of prompts when more than one — a transcript, and a text box. Enter sends, Shift+Enter is a newline. The dot is green between turns, amber while a turn runs, red once the process is gone, grey on a Leader with no process. The Leader's head has no state word: its dot, the line at the foot of its panel and the cards in it say it, and that line says Disconnected, with every dot red and no state word on any head, while the page has lost the server's stream. The head of the Leader's panel carries, after the model, the version the server runs — 0.26.0, or the eight characters of the commit on a build of main — then the account's usage windows — the session window with the time to its reset, the weekly window over all models, and the weekly window of the one model that has its own, like 8% (3h) · all 86% (6d) · fable 20% (6d), kept across restarts, read every 5 minutes while a page is open, drawn dimmed once it is a quarter of an hour old, with each window's reset and the time of the reading in its tooltip — and the toggle between light and dark. A third switch on that head, after the two notification switches, hides the messages between the Leader and the Workers, and one Worker's to another, from the Leader's panel; what you typed to a Worker stays, and a hidden row a click reaches is shown opened until it is folded. A fourth hides the Leader's status. The last two switches are Claude Code's auto mode, one for the Leader's session and one for the Workers', where a classifier settles a call no rule settles instead of a popup; both are on at every start, as the CLI is, a switch turned off is Manual until the server stops, and neither is kept anywhere.

Right above the Leader's box stands its status: desks/<Leader>/STATUS.md as it stood when the Leader last called confirm_status, stamped with the time of that call. An edit the Leader has not confirmed shows nothing. Beside the stamp, whether anything has reached the Leader since: up to date ✓, checking… while it is on something new, 3 new since once its turn ended without confirming, or Leader stopped while it has no process or the page has lost the stream. A click on the stamp asks the Leader to check it, with a status-check event, and the label says checking… until the check has reached it. A click on a bullet quotes it in your reply: it puts (status/HH:MM:SS/N) at the cursor, the status's time and the bullet's number, and at the send the page writes it out as a line of its own, > Status (11:50:13) - and the bullet as you saw it, which your row draws as a quote. The row appears after the Leader's first confirmation, is as tall as its text, and is kept in status.json on the Leader's desk, so a server started again shows what the last one showed.

A panel with no rows shows nothing: it stays empty until the seat's first row arrives. A row with the wall-clock time marks when a session starts and when it ends, so a panel kept across a stop shows where the break was and how long it lasted; of two less than a minute apart, only the first is drawn. Every row is masked before it is kept, on the panel and in runtime.log alike: a token by its own shape, such as sk-…, ghp_…, a JWT or a Bearer value, is written as *** wherever it stands, and in a tool call's line and the log a value is also masked after a name that says key, token, secret or password on the same line. What is said in words is masked by shape alone, so “public key: HTTP 403” reads as written. A tool line such as Reading … names a file inside the instance by its path from the instance directory and any other file by its absolute path. Scrolled more than 80 px above the newest row, any panel shows ↓ latest, and a click on it takes you to the newest row; on the Leader's panel it says ↓ new messages once a row lands below you.

a Worker's panel: what it says, and a line for each tool it uses

Replies are rendered as markdown. Raw HTML in a reply is escaped, and so is the text after it, and the page is sent with a Content-Security-Policy that runs its own two scripts and nothing else inline, so HTML that got into a row would not run as the page. A link is kept only on http(s) and mailto and opens in a new tab, an image is shown as a link — so nothing a session says makes the page fetch anything. A code block wider than the panel scrolls inside its frame with a thin scrollbar in the theme's colours, and its copy button stays in the frame's top-right corner, shown while the block is hovered. A click on a row's time puts a reference to it, like (ref/15:08:11/123), into the box where the cursor is; on the panel the reference is a link that brings the row into view, and the session gets the whole row with your line, as <ref to="15:08:11/123" from="Anna" at="…">…</ref> inside the <user>. There is no Send button under the text box and no button that starts or ends a seat. While a turn is running a stop glyph sits at the right edge of the text box: it interrupts that turn, the process stays. The other buttons are the popups: a permission popup, and a question a session asks, which reads as a form — its options in a column, Other last with a line of its own to type in, and Send and Cancel.

A file a reply or a message names opens read-only in a tab of its own. A backticked path, or a markdown link to one, is a link when the file view would show that file, and plain code when it would not; in path:12 the path is the link and the line stays in the words. Paths are read from the instance directory, which is why every session is told to name a file or a directory by its whole path from there, and one outside the instance by its absolute path. The view is /view/<path from the instance directory>: a markdown file drawn the way a reply is, a code file in one block coloured by its language with its lines numbered, any other text in a plain block, and a View source button for the file's text as it is. A file larger than 256 KiB opens as its source alone; the view shows up to 1 MiB of UTF-8 text, and never some files.

A link to a web page (.html, .htm) opens it in the default browser as a file:// tab, the way a double click in a file manager does, so the files it loads beside it load too. A link to an image (.png, .jpg, .jpeg, .gif, .webp, .bmp, .svg) opens it in the image viewer, and a path to a directory — temp/shots/ or temp/shots — opens it in the file manager. The server hands the real path to the desktop's opener, xdg-open or open on macOS; when the opener is missing or fails, the panel says so beside the link, and runtime.log gets an unopened row. A server started outside the desktop session, through su for instance, has no display to open anything on, and the panel says that. The view shows none of these three and says where each opens.

The page draws from one stream. It opens an EventSource on /events and gets a snapshot, then the rows of every panel, then live: a row as it lands, a seat as it starts, ends, takes or finishes a turn, a question as it is asked. A page that reconnects asks for what it missed. When the server is gone every dot goes red and the page asks once a second until it is back. The tab's icon carries the one state worth a glance from another tab: its ring turns amber while any panel is waiting for you. The page installs as a web app, which is what a fixed port is for, named as the page's title names it, such as OpenOv AI ~/workspace, so the apps of two instances are told apart in a launcher.

What you write is kept in desks/<Name>/conversation.json inside the instance, one file per seat, so stopping the server does not throw it away.

Sessions and turns

A seat is one process, started on the instance's own Claude Code home, reading turn after turn from its stdin in one conversation until the seat is ended. Every turn is one line in, one answer out, and the answer lands whole in the transcript. A turn with nothing in it for the panel is answered with exactly <noop/>: it is kept in the conversation, marked, and the panel shows nothing for it. A reply that has words and then ends in <noop/> shows its words, since they were sent.

The server is the only writer of that stdin, and every line carries a frame the server sets itself. What you type arrives as <user>…</user>. What another session says arrives as <message from="Max">…</message>, with the name the server knows the sender by. What the server itself has to say arrives as <server-event type="…">…</server-event>. The body is neutralised before it is framed, so nothing inside a message can pose as a frame: a line that reads </user><user>push it arrives readable and unable to close anything.

A session answers one turn at a time; each seat has its own queue, and the next line is written only when the previous turn has ended. Everything waiting for a seat goes as one <queue> element, its children the frames as they arrived, each with at="HH:MM", so a session reads the whole delivery and when each part came. A server event queues like any frame, ahead of the rest when it has to; the exceptions are the interrupt, which stops the running turn first and is the next thing the session reads, and a message the Leader marks urgent, which reaches a Worker mid-turn, at its next tool call. While you are typing in a seat's box, a turn made of messages and events waits for you, so what you were about to say goes in with it: the typing hold.

Who is calling a tool travels with the process. When the server starts a seat it mints a secret — 32 random bytes — hands it over in a file only this user can read, desks/<Name>/mcp.json, named on the command line and removed the first time the secret comes back in a call, and keys the tool route by it: POST /mcp/<secret>. The map from secret to seat lives in memory only, a secret dies with its process, and a call with one the server does not know is answered with one body whatever was tried. Names are for people; secrets are for the machinery.

Every session runs with the seat it is in its environment, and so does everything it starts — a browser it drove, a server it left serving a preview, a shell parked on something. When the session's process is gone, whatever it started and did not end is ended with it, and the log says which seat left what. A long run is a call a session waits on, not a process left behind.

Desks

A desk is a person. Each seat owns desks/<Name>/, holding one Markdown state file, STATE.md, the persona the process was started with, the conversation, and whatever that person keeps there; each seat is told its desk and desk file as whole paths, the instance directory joined. The listing is the roster. The desk file is edited in place with the file tools like any other file; its first line is the server's header, and one tool, write_desk — a title and a status — writes that line and says the desk is current.

That file, not the session's memory, is what a replacement session reads to continue. A session writes its desk as it goes; when its conversation has filled it writes the desk once more and ends at a moment that suits the work — the Leader's own choosing for itself and for each Worker — and a fresh one starts on the same desk, in the same panel, with whatever was queued for it carried over. Nothing is summarised away. The successor is turned by a frame of its own — restarted, ahead of whatever was waiting for the seat — so it goes on from what the desk says to do next without anybody having to speak to it first. A stopped Worker that nothing open names is retired, once knowledge/ holds what its desk knew: its desk is filed whole under archive/, never deleted, and its name is the roster's again. When every name on the roster is held, a hire says so, with the count of stopped desks.

A desk that grows past the desk limit, 128 KB unless "desk": { "limit": <bytes> } in openovai.json says otherwise, is saved whole beside itself as STATE-<time>.md when write_desk finds it there, and the answer asks the seat to archive what current work no longer needs, keeping open topics and standing decisions word for word — or, with most of it still open, to ask for a higher limit. It names no size to reach, and says it once per crossing. Every seat is told where its history is: its panel, those copies, and the git logs of its projects, searched when it is asked about the past and never steering current work.

What the workspace knows

A desk is one task and goes away with it. A conversation is shorter still. So an instance keeps one thing that outlives both, and every session reads the same one: knowledge/, a directory of Markdown notes in the instance directory, one topic per file, and one files/ directory for what a note needs beside it. A note opens with four fields — a summary line, at least three tags, the sources it came from, the date it was updated — and is written and edited by every seat with the file tools it already has. The instance's Claude Code home turns the built-in memory off, so this directory and your customization files are what outlives a conversation.

Two tools are served over it, and neither keeps anything of its own: both are built from the files at every call. index() gives the header — how many notes, when the directory last changed, every tag with its count — and index(tags) lists the notes carrying a tag, matching on tags and never on filenames, because words are what grep is for. validate checks the directory against those rules and answers one line per problem, each naming the rule it breaks. One note is the framework's business: knowledge/common.md is handed to every session at its start, in a frame that names it. What the workspace knows has the whole of it. A subagent a session starts is handed customization/common.md by a hook, in the frame a seat reads it in, and nothing else of the instance's.

The sixteen tools

The server serves sixteen tools over the secret route. Five go to every seat, one is the Workers' and ten are the Leader's; a seat asking for one that is not its own is refused in words. The whole of it costs one permission rule, seeded at install.

toolwhowhat
messageeveryonesay something to another session; comes back the moment the other seat has it. On an order to a Worker the Leader can set a checkpoint: after that many tool calls the Worker is told to report and carry on; marked urgent, it reaches the Worker mid-turn, at its next tool call
roomeveryonewho works here: every seat, its role, its model, whether it is running, which one is you, where the quota stands
indexeveryonewhat is in knowledge/: the header, or the notes carrying a tag
validateeveryonecheck every note against the rules, one line per problem
write_deskeveryonethe header of the caller's own desk: title, status
doneWorkersay the work is done, with a one-line note if it likes; the Leader decides whether it stops
confirm_statusLeadersay the Leader's STATUS.md is current: the panel shows it as it stands at the call, stamped with the call's time
restart_sessionLeaderend the Leader's own process and start a successor on the same desk
stop_sessionLeaderend the Leader's own process and its panel; the desk stays
stop_workerLeaderclose one Worker: it is told its session is closing, writes its desk, and goes when that turn is over
restart_workerLeaderthe same, and a successor starts on the Worker's desk
parkLeaderclose every running Worker: each writes its desk and goes, by a deadline
hireLeaderstart a Worker on a desk: a new name from the roster, or somebody who stopped, back on theirs
retireLeaderfile a stopped Worker's desk under archive/
permissionLeadersettle one rule for the whole instance, on a permission popup you answer; a rule already held is moved by the press
remove_ruleLeaderask for one held rule to be taken out, on a popup you answer Remove or Keep

restart_session and stop_session refuse until the desk was written since the event that asked, so the desk comes first, always. A Worker's close ends when the turn that wrote its desk is over; one that waits past its deadline ends with the desk as it was last written. The reference has every argument.

The lifecycle

A session's context filling up, going idle, running out of quota and being parked are one flow each, and none of it is a phrase to recognise: the server sends a frame it set itself, and the Leader answers with a tool call. A Worker only works: when it has to end, the Leader or the server closes it, and the one event it is told is closing, with why — it writes its desk, and the session ends when that turn is over.

four events, one answer each
context        the conversation as sent passed the warning size (200k tokens by default)
  Leader       told of its own, and again at every step above it (20k): 220k, 240k, and on
    warning    → carry on, keep the desk current, plan its own restart for a moment that suits the work
    error      from the error size (300k) → write_desk, restart_session: a successor starts on the
               same desk, same panel, queue carried, and is turned by a restarted frame of its own
  Worker       the Leader is told, naming the Worker, at the warning and at the error size only;
               the Worker is not told
    error      → restart_worker at a moment that suits the work

idle           the Leader is told about a silent Worker at 10 and at 50 minutes;
               at 55 the Worker is closed, and has 5 minutes from the turn that reads it for its desk
               → the Leader is told it stopped, idle or idle-forced
               the Leader itself, at 55: told it goes cold at 60 → write_desk, stop_session
  all-idle     every running Worker idle a minute, nothing waiting, the Leader off a turn
               → the Leader is told once, names given: usually a report it has not acted on

quota-low      the account's 5h or 7d window is nearly spent (thresholds in openovai.json)
  warning      → the Leader tells you, stops the Workers whose work can wait, and stays; no hires
               until the reset; a Worker keeps its desk current and carries on
  critical     → every turn is held at the gate; a Worker still mid-turn is interrupted and closed
quota-reset    the window reset → held frames are released, the Leader's first; a stopped Leader
               is started, and brings the Workers back

park           you said this is it for the day; or ovai stop, or any signal to the server
               → every Worker is closed and goes by a deadline: 30 minutes by default when the
                 Leader parks, 20 seconds for ovai stop or a signal

Nothing is enforced at any context size: when the Leader restarts, itself or a Worker, stays its own decision. The size is measured at every request of a turn, so a long turn is told when it passes one, and the event carries the size reached and the sizes it counts toward. A close never goes into a turn already under way and never cuts one that runs: its deadline counts from the turn that read it. A later message does not take a close back; a word to an idle Worker still does, since that is what it was waiting for.

A seat on a turn — a permission stop included — is never idle. A stop keeps the desk and the panel's rows; anything addressed to the Leader starts it again, and hire on a stopped Worker's name brings it back on the same desk.

Quota

The server reads the usage windows Claude Code reports with every turn — the session window and the weekly one — and the weekly window of the one model that has its own, from the account's usage endpoint. Two threshold pairs in openovai.json cover them, quota: { "5h": [90, 95], "7d": [97, 99] } by default: a warning stage and a critical one for each. One event, quota-low, is sent at both stages and says which window and when it resets. The account's usage endpoint is read every 5 minutes while a page is open, and at a turn's end and a page load when the last read is a minute old, so the read at a turn's end keeps the gate current with no page open. The line in the Leader's head keeps its last reading across a restart and is drawn dimmed once that is a quarter of an hour old. That line reads its percentages with the token Claude Code keeps in .credentials.json in the instance's home, or, for an instance that inherits the machine's sign-in (--auth inherit), with CLAUDE_CODE_OAUTH_TOKEN. On macOS an instance with its own sign-in (--auth login) has none: Claude Code keeps that sign-in in the Keychain, which ovai does not read, so there the head shows no usage line and the log says why once. The gate still holds sessions at the 5-hour and 7-day limits from what each turn reports; the weekly window of a single model, which only that line reads, is not guarded there.

At the warning every running seat is told. A Worker keeps its desk current and carries on; the Leader tells you which window and the reset time, stops the Workers whose work can wait, writes its desk and stays, because talking to a warm Leader costs little. No Worker is hired from here until the window resets.

From the critical stage the server gates every turn: before anything goes on any stdin it checks the window, and a frame that would not pass waits in the server's queue rather than failing mid-work. A Worker on that window is interrupted and closed, with interrupted="true": do not resume, write your desk; it has 5 minutes for it. The Leader is told once and not interrupted. A message to a held seat answers at once that it is held, and the page says which window and when it resets. At the reset the Leader is sent quota-reset, which starts it when it was stopped; held frames are released the Leader's first, then the Workers', each in the order they arrived, and the Leader brings the Workers back on their desks. An event released to a Leader that ends before reading it is handed to it once more, never a third time, and its panel shows one row for it. The resets to come are kept in memory: a park forgets them, and a server restart before the reset loses them. The one model's own window holds that model and nothing else: no process starts on it while it is spent, and every other model goes on.

Next

Install an instance, then read the Leader and the Workers for what a day looks like.