Skip to content

Automation reference

A way to run something automatically when a tab changes state.

It is written in Lua, a small scripting language, but a few lines is all you need.

This document is both an explanation for humans and the specification that the “let an AI write it” button in the settings screen hands to the AI.

Translations live next to this file as docs/AUTOMATION.<code>.md (for example docs/AUTOMATION.ja.md) and are picked automatically from your language setting.


Put a file named after an event into the automation folder and it runs at that moment. Only add the ones you need.

File name When it runs
on_start.lua Once the tab has started and settled (see below)
on_done.lua When the AI has finished answering something it was asked
on_question.lua When the AI asks something or offers choices
on_exit.lua When the session ends (including disconnects and crashes)
on_busy.lua When an answer starts (advanced). Set Checking on a tab that keeps working in Settings and it runs again, at that interval, for as long as the tab is still working
on_notify.lua When the program rings the terminal — a bell, an OSC notification, even over ssh. The second variable holds the text. Forward it with shikisha.notify(...), route it, or log it; the on-screen toast still shows
_shared.lua Loaded before all of the above. Put shared helper functions here

on_done.lua and on_busy.lua only run once something has been sent to the tab. Every program prints something as it starts, which makes the screen move and then stop — the same shape as an answer — so without this a banner would be forwarded as if it were a reply.

on_start.lua does not run the instant the tab appears. An AI CLI ignores input until it has drawn its own prompt, so it runs once the program has produced output and the screen has stopped changing — usually a second or two. You do not have to wait yourself.

Write only the body of the work in the file. No function ... end wrapper.

-- example of on_done.lua
shikisha.send_to_tab(2, "Please review this code:\n" .. tab.output)

tab is available in every event.

Variable Contents
tab.index Tab number (starting at 1)
tab.name Tab name
tab.id The automation name from the settings, if it has one. The one handle that survives a rename — branch on this, not the number or the display name. nil when unset
tab.output The latest response text (no earlier history)
tab.state "BUSY" / "DONE" / "QUESTION" / "WAIT" / "EXIT"
tab.profile Name of the profile in effect
tab.chain_depth How many times this was handed on automatically. 0 means a human started it
tab.locked Whether input is locked
tab.is_model Whether this tab talks to a model over an API rather than running a CLI
tab.reply A model tab’s reply, exactly as it came back (only on such a tab). tab.output is the same text as the screen drew it, wrapped

on_question.lua gets a second variable screen holding the whole screen text; on_notify.lua gets a second variable holding the notification text.


A tab is addressed by its name used by automation – the field on the tab’s settings page, written as id in config.json.

{ "name": "Review", "id": "reviewer", "command": "codex" }
shikisha.send_to_tab("reviewer", "please review") -- recommended
shikisha.send_to_tab(2, "please review") -- by number too (changes on reorder)

The name shown on the tab will not reach it. That name is a heading: you can write anything there, including the same thing on two tabs. Two tabs called “Review” would leave nobody able to say which one received the work, so the address is the automation name only. It is unique within the desk and does not change when you rename the tab.

Every tab has one even if you never chose it. A tab added from the screen is given two short words (calm-otter, brave-finch); one written into config.json without an id gets one made from its name, or from its command when it has no name. The tab’s settings page shows it and lets you change it.

The name is an address, and goes back to be drawn again when its tab closes. What the app keeps about a tab – its conversations, the jobs it leads and the work it was handed, its mail, its place in a split – is kept under who the tab is: a uid the app writes on the tab’s line in the settings when the tab is made, and never hands to another tab. A new tab that is given a closed tab’s name starts with none of it.

Command Description
shikisha.send_to_tab(tab, "text") Give a tab an instruction and run it. Works on this tab too (automatic chain +1)
shikisha.ask_tab(tab_id, "line", "text", {timeout_ms=…}) Ask another tab’s AI and get its reply back. When a message contains <@ID>, that is a SHIKISHA tab: call this with that ID to hand it work (a review, a question) and wait. line is one short line for the chat (AIConfer shows it; one line, no longer than the settings allow, 80 characters unless changed); text is everything the tab is sent. Waits while the tab is busy, sends, waits for it to finish, and returns {state, reply, round, max_rounds, same_folder, note}. state is DONE with the reply, QUESTION when it waits for approval, or PENDING when it is still working at the timeout (50 min by default) – its reply is then typed into your tab when it finishes. Stop when round reaches max_rounds. same_folder: false means it cannot see your uncommitted changes. Through the pipe or MCP only
shikisha.send(tab, "text") Send raw keystrokes (newline is \r). For answering prompts, not for instructions
shikisha.tab_run(tab_id, "command", {timeout_ms=…}) Run a command in a terminal tab and get its output. Only in a terminal the person named with @ in what they last sent you. Waits while the tab is busy, types the command, waits for it to finish, and returns {state, reply} with the output in reply (PENDING at the timeout, the output then typed into your tab). Through the pipe or MCP only
shikisha.browser_do(tab_id, "goal", {timeout_ms=…}) Have a web page driven toward a goal and get what it found. Only a page the person named with @. Runs the page’s 🗣 words run and returns {state, reply, note}: DONE with what the page shows about the goal, STUCK with why, STOPPED when someone stopped it, PENDING at the timeout. One page at a time. Through the pipe or MCP only
shikisha.tab_list() The tabs of this desk, as {id, name, kind (ai / terminal / page), folder, you, named}. named is a tab the person named in what they last sent the caller. Through the pipe or MCP only
shikisha.tab_conversation(tab_id, {want=6, before=…}) Another tab’s conversation, as the phone’s reader shows it. Read from the CLI’s own record – on this PC, or on the server or MicroVM the tab runs on – so a long answer comes back whole, not cut to the screen. Returns {turns = {{who = "you" / "ai", text}, …}, from, more, source}, oldest first: the last want things said (1 to 40). For older ones pass the from you got as before; more is false once the start is reached. A tab whose CLI keeps no record answers source = "none" – use tab_screen. Through the pipe or MCP only
shikisha.note(tab, "text") Write a line on that tab’s screen for the person watching. Nothing is sent to what runs there and no answer is expected
shikisha.wait(tab, "pattern", ms) Wait until the text appears on screen; true if it did
shikisha.sleep(ms) Wait (other tabs keep running while you wait)
shikisha.state(tab) Read the state right now (use this as a loop condition)
shikisha.wait_state(tab, "DONE", ms) Wait until it reaches that state
shikisha.notify("target", "text") Notify Slack / Discord / Telegram, this PC’s own notification area, or a phone that registered itself (only configured targets)
shikisha.restart(tab) Restart that tab, carrying its conversation over. shikisha.restart(tab, "fresh") starts a new one
shikisha.log("text") Record in logs/hooks.log
shikisha.set_session("id") Say which conversation THIS tab’s CLI is running, so a restart can pick it up. No tab argument: the caller is the tab
shikisha.report_prompt("text") Say what a person just asked THIS tab’s CLI. A folder with Auto on writes its name and summary from these. Claude Code’s and Codex’s own hooks report through here, which also catches what was typed straight into the terminal
shikisha.set_state("BUSY") Say what THIS tab is doing, instead of leaving it to be read off the screen: BUSY, QUESTION, DONE or WAIT. This is how an AI CLI’s own hooks drive the state dot. A second argument is the sender’s clock in milliseconds, so reports that overtake each other still apply in the order they were said
shikisha.set_helper("a1f2", true) Say that a helper THIS tab’s program runs on the side (a subagent, by the program’s own id for it) began (true) or ended (false). While one runs, a tab whose turn is over reads BACKGROUND rather than done. An AI CLI’s own hooks report through this
shikisha.set_running({"a1f2"}, false) Say everything THIS tab’s program still has running beside its conversation: the helpers by id, and whether anything that is not a helper (a command left running) is still going. Replaces what was said before; set_running({}, false) says nothing runs. What is not heard of again for three hours is let go of
shikisha.set_status("key", "text", tab) Say what a tab is doing, in its own words, under its name in the tab bar. key lets several sources speak without overwriting each other; an empty text removes that one. Leave tab out and it is THIS tab
shikisha.set_progress(0.4, "label", tab) How far along, 0..1, shown beside the status. nil removes it. Leave tab out and it is THIS tab

A CLI that has never heard of this app can say it too. The notification escapes every terminal understands land in the same place, with nothing to set up — useful over ssh, or inside a container, where nothing of ours is installed:

Terminal window
printf '\e]777;notify;Build;3 tests failed\a' # title and body
printf '\e]9;build finished\a' # body only

It appears under that tab’s name and, if you are looking at a different tab, as a one-line toast. Looking at the tab already is not news, so the toast is held back. | shikisha.get_var("key") / shikisha.set_var("key", value) | Remembered variables, shared inside the desk |

If on_question.lua returns a string, that string is sent automatically. Returning nil (or nothing) leaves the decision to the human.

An AI CLI takes a pasted instruction and the Enter that runs it as two separate events, and drops the Enter if it arrives before the paste has been taken in. send_to_tab handles that for you.

-- Right. One call: the text is entered and run
shikisha.send_to_tab(tab, "You are on Bianca's side. Argue your case.")
-- Wrong. The text lands in the input box and stays there
shikisha.send(tab, "You are on Bianca's side. Argue your case.")
shikisha.send(tab, "\r")

Do not paper over it with sleep. A fixed wait is a guess about how long the other program takes to be ready, and that changes with the machine, the model and the length of the prompt — it will hold until the day it does not. send_to_tab waits on the actual event instead of on the clock.

send remains the right tool for keystrokes an AI is already waiting for — answering a confirmation with "1\r", or driving a shell.


Give a tab its opening instruction (on_start.lua)

Section titled “Give a tab its opening instruction (on_start.lua)”
shikisha.send_to_tab(tab, "Summarise what changed in this project yesterday.")

Nothing else is needed — the hook already waits until the program is ready to be typed at.

Resume yesterday’s work just by starting (on_start.lua)

Section titled “Resume yesterday’s work just by starting (on_start.lua)”
if not shikisha.wait(tab, "%$ $", 15000) then return end
shikisha.send(tab, "cd /srv/myproj\r")
shikisha.wait(tab, "%$ $", 5000)
shikisha.send(tab, "claude --continue\r") -- pick the previous conversation back up

To choose which past conversation to resume, use claude --resume. It shows a list, and picking from that list can be automated too:

shikisha.send(tab, "claude --resume\r")
if shikisha.wait(tab, "[Ss]elect", 8000) then
shikisha.send(tab, "\r") -- choose the topmost session
end

Approve automatically, but hand risky questions to a human (on_question.lua)

Section titled “Approve automatically, but hand risky questions to a human (on_question.lua)”
if screen:match("delete") or screen:match("rm %-rf") then
return nil -- leave it to the human
end
return "1\r" -- pick choice 1

Bounce a review between A and B, stopping after 5 rounds (on_done.lua)

Section titled “Bounce a review between A and B, stopping after 5 rounds (on_done.lua)”
-- do nothing when a human gave the instruction directly
if tab.chain_depth == 0 then return end
local rounds = shikisha.get_var("rounds") or 0
if tab.output:match("LGTM") or rounds >= 5 then
shikisha.notify("slack", "Review finished (" .. rounds .. " rounds)")
return -- doing nothing = the loop ends
end
shikisha.set_var("rounds", rounds + 1)
shikisha.send_to_tab(1, "Please fix these points:\n" .. tab.output)

Reconnect automatically after a disconnect (on_exit.lua)

Section titled “Reconnect automatically after a disconnect (on_exit.lua)”
local n = (shikisha.get_var("retry") or 0) + 1
if n > 5 then
shikisha.notify("slack", tab.name .. " keeps dying")
return
end
shikisha.set_var("retry", n)
shikisha.sleep(2000)
shikisha.restart(tab) -- after the restart, on_start runs again

The screen and the other tabs keep running while you sleep. You choose the interval.

-- while it is working, record every 30 seconds
while shikisha.state(tab) == "BUSY" do
shikisha.sleep(30000)
shikisha.log(tab.name .. " is still working")
end

tab.state is the state at the moment you were called, so use shikisha.state(tab) (the state right now) as the loop condition. When a tab exits or restarts, waiting loops are discarded automatically.

Without the loop: set Checking on a tab that keeps working in Settings, and on_busy.lua is simply run again at that interval while the tab keeps working. Each run is told the state and the screen as they are now, so a watchdog can be written as one if instead of a loop:

-- a turn that has run this long has stopped answering, not started thinking
local since = shikisha.epoch_ms() - (shikisha.get_var("since_" .. tab.index) or 0)
if shikisha.get_var("since_" .. tab.index) == nil then
shikisha.set_var("since_" .. tab.index, shikisha.epoch_ms())
elseif since > 900000 then
shikisha.notify(tab.name .. " has been working for 15 minutes without a word")
shikisha.set_var("since_" .. tab.index, shikisha.epoch_ms())
end

You are only asked again about a tab you were told about in the first place, and never about one waiting on a person.

Just notify Slack when it is done (on_done.lua)

Section titled “Just notify Slack when it is done (on_done.lua)”
shikisha.notify("slack", tab.name .. " finished:\n" .. tab.output)

Stop before it starts, and leave a note (on_done.lua)

Section titled “Stop before it starts, and leave a note (on_done.lua)”
if shikisha.state(2) ~= "WAIT" then
shikisha.skip("tab 2 is still working") -- nothing below this line runs
end
shikisha.send_to_tab(2, tab.output)

skip ends this run where it is called and puts one line on the tab’s screen and in logs/hooks.log. Use it whenever an automation decides not to act: a hand-over that quietly does nothing looks exactly like a hand-over that is broken.


Only the reply is forwarded — not the terminal furniture around it. Startup banners, input-box borders, and the hint and status lines a CLI keeps at the bottom (? for shortcuts, the model and directory readout) are dropped.

They are found by position and by change, never by matching their text. Rows below the cursor belong to the input box whatever they say; everything else is compared against a snapshot taken the instant the prompt was submitted, so anything already on screen before the reply existed is not part of the reply. A CLI can reword or translate its status line and this keeps working, and a reply can contain any wording at all without risk of being eaten.

The instruction itself is not sent back either. A reply begins on the line after the one that was submitted, which matters when the instruction was long enough to wrap: the second half of it used to arrive at the top of the answer, on its own, looking like something the other side had said.

One thing is beyond reach: narrowing the window while a reply is arriving truncates it, because the terminal clips every stored row to the new width and the discarded text is gone. Widening and height changes are harmless. A narrowing mid-reply is recorded in logs/hooks.log so a short answer is not a mystery.

Handing work to a tab does not move the screen. Say so when you want to be watched:

shikisha.show("reviewer") -- put this tab on screen
shikisha.send_to_tab("reviewer", msg) -- ...and hand it the work

Two lines, in that order, and nothing moves behind your back. shikisha.show(0) goes back to the board.

The person always outranks the script. show does nothing if they turned Auto-switch off in the general settings, if they moved the view themselves in the last few seconds, or while the settings screen is open — you are never pulled away from something you are reading.

The ball still flies on the board whether or not the screen follows it: it shows who is holding the work, which is not the same question as where you are looking.


send_to_tab types and submits. To leave something in the box for a person to add to, send it as a paste and never send the newline:

shikisha.draft_to_tab("ai", "Read lp.html.
")

The text lands in the input box and stays there. The newlines are characters, not keypresses, so nothing is sent and the person can add their own instructions before pressing Enter. This program does not count it as a submission either, so on_done will not fire on that tab.

A draft does not end the chain — it puts a person in it. The ball moves to that tab and waits there, carrying its depth, and the view follows so you arrive where you are needed. Typing there does not break the chain the way typing into any other tab does; you are taking your turn, not taking over. When you send, the count continues from where it was, and the chain limit still applies — a loop with a person in it is still a loop.

It refuses to draft into a shell, and says so in logs/hooks.log. A terminal program declares whether it understands pasted text; a shell does not, and the same bytes there would run as a command. Measured: cmd.exe and powershell.exe do not declare it, Claude Code does. The check reads that declaration rather than guessing from the command name.

Keep the draft short. A long paste is collapsed to [Pasted text #1 +N lines], and the person cannot read what they are about to send.


A browser can join the orchestra. Driven from the window, Windows already carries the engine, so nothing is downloaded and nothing is installed.

With no window – on a server – it uses whatever browser that machine has. Install one first (apt install chromium) and that is the one it uses. With none, it fetches a version it names, and only at the moment one is first asked for. Nothing is bundled, so anybody who never opens a page pays nothing for this.

Declare one alongside the tabs of a desk. A browser you declare becomes a tab, numbered after the sessions — Ctrl+B and its number switches to it like any other.

{
"name": "LP review",
"browsers": [{ "id": "br", "url": "https://example.com/login" }],
"tabs": [{ "name": "Claude", "id": "ai", "command": "claude" }]
}

While a device – the window, or a phone – is connected to a server, you choose which machine draws the pages automation opens (Settings, “where pages are drawn”).

  • On this machine (the default) – watchable from a phone, still working with nobody connected, and one signed-in session for every device. The screen is relayed as a picture.
  • On the connected device – faster and sharper, but it needs that device to be there, and only it can see the page (a page that has to be watched from elsewhere belongs on this machine).

Either way, the page reaches the network from the machine the agents are on. So browser_open("x", "http://localhost:3000/") is always that machine’s port 3000. Names are resolved there too, which is how a page reaches a private network only that machine can see.

A page already open does not move. Changing the setting changes where the next page is drawn.

A page has its own vocabulary. Session states — working, done, asking — say nothing about a document, so browsers get their own names.

File When
on_load.lua the page finished loading (on every navigation)
on_press.lua the human pressed the banner button

The banner does not appear on its own. shikisha.browser_ask puts it under the page — your words on the left, the button on the right — and pressing it calls on_press. The app draws it, not the page: the page is held back by the banner’s height and can neither see nor press it, so a press is always a person’s. It stays up across navigations until shikisha.browser_unask, and a phone looking at the page can press it too.

To let a person choose the page before handing it over, shikisha.browser_nav puts back / forward / reload / an address box in a row above it. Like the banner, this is not injected into the page: the page moves down and the app draws in the gap, so it survives navigation and never covers the site’s own sticky header.

shikisha.browser_nav(page.id) -- all of them
shikisha.browser_nav(page.id, { reload = true, url = true }) -- pick some
-- back / forward / reload / url / develop (hard reload, picking, DevTools, source, DOM)
-- point (phone only: a tap clicks where the finger is, or moves a pointer)
shikisha.browser_unnav(page.id) -- take it away

Back and forward grey out when there is nowhere to go. The address box takes an address or words: an address opens, and anything else is searched for on Google. point is drawn only for somebody watching from a phone. The same checkboxes live in the settings screen for a browser tab, so this works with no Lua at all; a call from Lua wins over the setting.

The banner works the same way: fill in its words and button text under “Banner” in the settings and it is there from the moment the page opens. Then the only file you write is

-- scripts/lp/on_press.lua
shikisha.draft_to_tab("ai", shikisha.browser_html(page.id))

Typing an address still fires on_load, so if you only want the page handed over when a person says so, leave on_load.lua empty and write on_press.lua. None of this touches the chain depth — that only counts handoffs to another tab.

What arrives is page, not tab: page.index (the number on screen), page.id (the name automation points at), page.name (what a person reads), page.url, and page.complete — false means load never came and this fired at DOM-ready instead.

draft_to_tab and send_to_tab wait until the other side can actually take input, so handing work to a CLI that has not finished starting does not silently vanish.

Then drive it from automation:

-- Let a person log in. The bar appears across the bottom of the page.
local why = shikisha.browser_wait("br", {
selector = "#dashboard", -- reaching this ends the wait
ask = "Please sign in", -- so does pressing the button
timeout_ms = 300000,
})
shikisha.log("ended by: " .. why) -- selector / button / timeout
shikisha.browser_fill("br", "#title", answer)
shikisha.browser_click("br", { xpath = '//button[text()="Save"]' })
local html = shikisha.browser_html("br")

A selector is "#id" (CSS), { xpath = "..." }, or { ref = N }. XPath earns its place on forms and admin pages, where “the cell beside the label that reads Name” has no CSS spelling.

The numbers for { ref = N } come from browser_digest:

local list = shikisha.browser_digest("br")
-- [1] textbox "Search" placeholder="Search"
-- [2] button "Search"
-- [3] link "Help" https://example.com/help
shikisha.browser_fill("br", { ref = 1 }, "haiku")
shikisha.browser_click("br", { ref = 2 })

The digest distills the page down to only its operable elements. Roles and names come from the browser’s own accessibility tree (the same computation a screen reader sees), and JS-clickables with no standard role (a cursor:pointer <div>, say) are supplemented with a * mark, like div*. It is orders of magnitude shorter than raw HTML, and removes the need to guess selectors.

Operations on { ref = N } are genuine input (trusted mouse/key events over CDP): sites that ignore synthetic events cannot tell them from a human’s click or typing. Multibyte text lands one committed character at a time, no IME involved.

Numbers are bound to the page as it was digested. Navigation or a re-render voids them, and an operation on a stale number stops with a clear “take a new digest” error — it never silently clicks something else.

On top of that, click / fill on { ref = N } return an echo of what was really operated on as their second value (e.g. visible, link 「Help」; a fill’s echo names the field by its attributes only, never the value). A mixed-up number denounces itself in its own answer.

On replay and portability: { ref = N } is an ordinary selector with the same meaning in every execution mode (automation scripts, the composer’s ▶ Lua run mode, a page’s 🗣 run). But the numbers refer to “the latest browser_digest listing” — they are not what you carry around.

That is why execution and recording are independent. Every executed op is rewritten in a durable form into the replay journal: during a 🗣 run it lands on that page’s 📼 sheet, and a script of your own drains it with shikisha.take_replay() into its run’s replay.lua. In it a { ref = N } becomes an anchor derived from the element it actually touched (a human-made #id, else a unique text/attribute XPath — same hygiene as the 📼 recorder, machine-minted ids refused), and browser_digest never appears. So:

  • the currency of execution = refs (maximum capability: shadow DOM reach, genuine input, friendly to small models)
  • the currency of portability = replay.lua (plain css / xpath only; paste it into the ▶ run mode, wire it into an automation, or run it on another PC’s SHIKISHA as-is)

A run’s replay.lua is downloaded from the button at the top right of the result view that opens when the run finishes. An op with no derivable durable anchor is never silently dropped — it stays as a -- click (…): what was clicked comment.

Looking for an element answers with three states — visible, off_screen, not_found — because which one it is decides whether to doubt the selector or the waiting.

Whether a missing element stops the script is chosen per call. The default raises; { on_missing = "continue" } returns the state instead. A cookie banner that is sometimes absent is not a failure, and only the caller knows that.

The button is offered for the whole wait, even when a selector is given. A condition that stops matching after a site redesign should cost a click, not a hang. And since the wait reports which of the three ended it, a selector that has quietly stopped working shows up as every wait ending on the button.

click / fill auto-wait. An action waits until the element appears → is visible → stops moving (identical rect on consecutive frames) → is enabled before acting. Retries back off 0/20/100/100/500ms and cycle scroll alignments to shake off sticky overlays; when navigation destroys the JS world, an outer retry re-enters the new document. That is why a replay.lua fired line-after-line with no pauses — acting on the next page right after browser_go — just works. The wait is capped at 10s per action, and an element that exists but never settles is acted on anyway (no new failure modes).

Values are never spliced into code. Everything handed to fill goes to the page as data, so an answer full of quotes and angle brackets arrives intact and stays inert. There is deliberately no way to hand raw JavaScript to a page.

Only http and https pages open. A single-line <input> cannot hold a newline — that is HTML, not this program — so multi-line values need a textarea.


Several brakes keep automation from running away.

  • Automatic chain limit … the number of consecutive automatic hand-offs between AIs is counted and stops at the limit (10 by default). Typing something yourself resets it to 0
  • Manual work wins … nothing is sent automatically for 5 seconds after you touch a tab
  • Emergency stop … Ctrl+B x halts all automation at once and sends every AI that is mid-turn its own interrupt key (interrupt in its profile: Esc for Claude Code, Codex and Gemini, Ctrl+C for Aider). Ctrl+B a toggles automation back on. The status bar carries the same button, in the same corner on every screen
  • Input lock … put 🔒 on the middle tabs so nobody instructs them by mistake
  • Sandbox … automation can neither touch files nor reach the internet by default. Notifications only go to the targets you registered (Slack / Discord / Telegram, this PC, a registered phone)

6. Files and network access (advanced, off by default)

Section titled “6. Files and network access (advanced, off by default)”

When you really need it, register a “gateway” inside a desk in config.json and that desk’s automation can use it. It cannot be edited from the settings screen (the impact is large, so it is only for people who edit the file directly).

// "desks": [ { "name": "…", here ↓ } ]
"capabilities": {
"files": {
"reports": { "dir": "reports", "read": true, "write": true }
},
"http": {
"github-issue": {
"url": "https://api.github.com/repos/me/proj/issues",
"method": "POST",
"auth_from_secrets": "github_token"
}
}
}
shikisha.write_file("reports", "review.md", tab.output)
local prev = shikisha.read_file("reports", "review.md")
shikisha.http("github-issue", '{"title":"Findings","body":"..."}')
Command Description
shikisha.now([format]) The local date and time as text
shikisha.write_file(gateway, filename, text) Write into a registered folder
shikisha.read_file(gateway, filename) Read from a registered folder
shikisha.http(gateway, body) Send to a registered URL (the app adds the credentials)

Why this is safe: scripts cannot assemble paths or URLs — they can only call registered names. Auth tokens are invisible to scripts; the app attaches them. config.json, secrets.json, .env and .lua files can never be read or written, even when they sit inside an allowed folder.

If you need more freedom, raw paths and raw URLs are available too (empty by default = everything denied):

"capabilities": {
"allow_dirs": ["reports"],
"allow_hosts": ["api.example.com"]
}
shikisha.write_path("reports/a.md", "text")
shikisha.http_raw("https://api.example.com/hook", '{"x":1}')

Hosts are matched exactly and only https is allowed (tricks like api.example.com.evil.com are rejected). Every file and network operation is recorded in logs/hooks.log.

Gateways, automation permissions, notification destinations and git settings are each desk’s own. There is no app-wide version of any of them: what a desk does not have, it does not have (no gateways, the standard permissions, no destinations, the built-in git settings). Work’s repositories beside your own on one machine never share a chat. The AI providers are the exception: one list for the whole app (Settings › AI agents), which every desk’s tabs and scripts reach by name.

"desks": [
{
"name": "work",
"id": "work",
"capabilities": { "http": { "deploy": { "url": "https://example.com/deploy" } } },
"automation_permissions": { "write_path": { "ai": false } },
"notify": {
"work-slack": { "type": "slack", "webhook": "@notify/work/work-slack" },
"This PC": { "type": "windows" }
},
"primary_notify": "work-slack", // where notify(text) with no name lands
"git": { "protect": ["main", "release/*"] },
"projects": [ {
"name": "api", "at": "D:/src/api", "git_account": "work",
"bring": [
{ "pattern": "node_modules/", "how": "link" },
{ "pattern": ".env", "how": "replace", "replace": [ { "find": "^PORT=\\d+$", "with": "PORT=3001", "regex": true } ] },
{ "from": "D:/templates/local.json", "to": "config/local.json", "how": "copy" }
]
} ]
}
]

A value starting with @ is the name of a secret. Registered from the settings screen, keys and webhooks are stored encrypted and only their names are written here. A new desk can start as a copy of the one you are on, keys included.

The git accounts are the app’s, written at the top of the file beside the providers – "git_accounts": [ { "name": "work", "login": "me-at-work", "owners": ["my-company"] }, { "name": "home", "method": "ssh", "key": "C:/Users/me/.ssh/id_home" } ] – and every desk’s projects choose among the same list. A token is not written here either: it is filed under git/<name> from Settings > GitHub / Git. The displayed token name is label; name is the stable identifier used by projects and secrets. The UI generates it on creation and keeps it when the label changes. Existing identifiers and stored tokens remain usable. A project names the one the git column beside its folders signs in with (git_account), and a git tab names its own. Absent, git on this PC signs in the way it already does; "@pc:<login>" names one of the GitHub accounts it holds, for a PC holding two, and "@gh:<host>/<login>" one of the accounts GitHub CLI (gh) is signed in as. Pull request numbers are read with the same account. GITHUB_TOKEN in the environment is not read.

bring is what a new worktree of the project gets beyond what git carries (the project’s page in the settings edits it). A pattern is a line of the project’s .gitignore and covers everything that line makes git ignore; from/to puts a file from anywhere at a place inside the worktree. how is copy, replace (copy, then each find becomes with – a regular expression when regex is set, with ^ and $ at each line), link, or skip. A line with no rule does what the settings page shows beside it. The project’s setup command runs after all of this.


7. Driving it from outside (the external API)

Section titled “7. Driving it from outside (the external API)”

A program outside the app can call the same commands you write in Lua. Same names, same arguments — there is no second vocabulary to learn.

The door is a named pipe, \\.\pipe\shikisha-<pid>. One JSON object per line, one line of answer back:

→ {"token":"…"} the handshake, once
← {"ok":true,"result":"hello"}
→ {"id":"1","method":"send_to_tab","params":["reviewer","status?"]}
← {"id":"1","ok":true,"result":null}
→ {"id":"2","method":"list"}
← {"id":"2","ok":true,"result":["browser_click","browser_close", … ]}

method is a command from section 9 with the shikisha. taken off. params are its arguments in order. list answers with every command the caller is allowed to run, read off the app’s own table — so it can never fall behind what the app can actually do.

For loops and branches, hand over a whole chunk in one call:

→ {"id":"3","method":"lua","params":["for i=1,3 do shikisha.send_to_tab(i,'ping') end"]}
← {"id":"3","ok":true,"result":[null,null]}

The answer to lua is always a pair: the first value is the error, or null when the chunk ran, followed by whatever it returned.

On the settings screen it is the External control card; changing it there takes effect the moment you save, with no restart. In the file it is one line:

"external_api": { "access": "children" } // the default
Value Who can call
children Only what the app started — a tab’s CLI, and whatever that starts in turn
user Anything running as you. The token is also written to data\api-token
off Nothing. The pipe is not created at all

Every tab’s process is launched knowing three things, so an AI sitting in a tab needs no setup at all:

Variable Holds
SHIKISHA_PIPE The pipe to connect to
SHIKISHA_TOKEN That tab’s own key, minted for it at launch
SHIKISHA_TAB Which tab it is sitting in

Because the key is the tab’s own, a call arrives already knowing who is making it — and what that tab sends counts against the same chain limit (section 5) as work handed over on screen. The API is not a way around the brakes. If an AI is what is running in that tab, what it may call is what Settings > Automation permissions (section 9) allows an AI.

What the token protects, and what it does not. The pipe is created with an access list naming your account and nobody else, so another account cannot reach it. Another program running as you can read the environment of your own processes, and an AI in a tab can copy its key into its own log. What this stops is an accident, and another account — not someone who is already you.

The first caller of each session is written to logs/hooks.log, along with any connection that presented no valid key.

Every tab finds a command called shikisha on its PATH. It is these same commands with another door: shikisha tab_run shell "make" is shikisha.tab_run("shell", "make"). The first word names the command and the rest are its arguments; an argument written as JSON ({"want":3}) is passed as that value, and every other one as text. It talks to the app through the tab’s own key, so what it asks counts against that tab, under that tab’s permissions – the same table that decides what an AI may call through MCP. shikisha list shows the commands the tab may call.

The ones an AI in a tab is taught to use:

shikisha ask_tab ID "a line" "what to do" # another AI: it does the work and replies
shikisha tab_run ID "a command" # a terminal: one command, its output back
shikisha browser_do ID "what to get done" # a web page: its 🗣 run driven toward the goal, what it found back
shikisha tab_list # the tabs of this desk and what each is
shikisha tab_conversation ID '{"want":3}' # the last things said in that tab's conversation

A terminal or a page is only driven when the person named it with @ in what they last sent the AI (a terminal can be a server somewhere). Using the wrong command for a tab says which one to use.

ask_tab, tab_run and browser_do wait for the other tab and print its reply, ending in one line that says what happened ([shikisha] DONE, STILL WORKING, WAITING, …). From here they wait a little under two minutes unless given a timeout_ms of their own, because an AI’s shell gives up on a command at about that point; when the other tab is not done by then, it says so, and the reply is typed into the asking tab when it comes. What any other command answers is printed as it is: a text as text, anything else as JSON. shikisha skill prints the skill that explains this to an AI.

This is what @ in the input bar is for: choosing a tab puts <@ID> in what is sent, and the skill tells the AI to run shikisha ask_tab with that ID. The first @ asks before the skill is written (Settings > AI agents puts it in or takes it out).

Orchestration: one AI sees a job through with others

Section titled “Orchestration: one AI sees a job through with others”

ask_tab is one question and one answer. For a job with several steps and several tabs – “have <@claude> implement it and <@codex> review it until nothing is left” – the AI you asked becomes the job’s lead and hands out tasks with the orchestration commands (the list is in chapter 9). The steps are not a mode of this app; the lead strings the commands together as your request asks, and the app keeps the record:

shikisha job_open "fix the parser and get it reviewed"
shikisha task_add "Implement the fix in src/parser.rs. Done when cargo test passes."
shikisha assign t1 claude # the task goes to <@claude>, with how to report
shikisha inbox wait # the lead waits; the report arrives here
shikisha task_add "Review the fix on branch fix-parser." '{"waits_on":["t1"]}'
shikisha assign t2 codex
shikisha inbox wait '{"dealt":1}' # the first mail is dealt with; wait for the review
shikisha inbox '{"dealt":2}' # the review's report is dealt with too
shikisha let_go a1 # each worker's tab, once its part is over
shikisha let_go a2
shikisha job_close "fixed and reviewed"

What keeps it going without a person in the loop:

  • A report that says how it went. A worker ends with shikisha report done "<what it did>" "<what it found>" "<what is left>", or failed in place of done, once. A worker that stops without reporting, waits for your approval, hits its usage limit or whose program ends is raised in the lead’s inbox, and you are notified when it waits for you.
  • Questions go to the lead, not to you. shikisha ask_lead waits for the lead’s shikisha answer.
  • Nothing is lost. The inbox hands the same mail over again until the reader says it has dealt with it, and a tab that is not waiting for its mail is told in one typed line when it is free to read it.
  • The next step is always given. Every answer carries next: what to do next, as the command to run wherever there is one. A refusal says why, and names the command that puts it right when there is one. job_close refuses while anything is left open and lists what, with the command for each.
  • Bounded. Work goes only to tabs you named with @ or that the job opened itself (open_ai_tab), the number of assignments per job and how deep jobs may nest are set under Settings > Limits on handing work, and a report counts only from the tab and the run of its program that was given the work.

A decision you should make yourself (merging to main, say) is asked with decision_open ... to person: you are notified, and you answer on the job’s panel. The lead reads the full guide with shikisha skill orchestration.

Tabs on a server or a MicroVM. An AI there has no shikisha command of its own, so it cannot report or ask its lead. Put the SHIKISHA bridge on that machine (Settings > Where it runs > the machine > “Put the SHIKISHA bridge on this machine”): a small program (about 1 MB, in ~/.local/share/shikisha/bridge/) that carries the shikisha command of the AI tabs there to this app and reads their conversation records there. The box is ticked already when you add a machine, and a machine added before asks once, the first time one of its tabs is opened. It is put there only while that box is ticked, runs while this app is using a tab on that machine – and, when you chose to keep that machine’s AIs running while the app is away, while they run – and is deleted when you untick it. Until then, a task cannot be assigned to a tab there; ask_tab still works.

An AI client that speaks the Model Context Protocol – Claude Code, and the others – can be given these commands as its own tools:

{ "command": "<path>\\SHIKISHA-TERM.exe", "args": ["--mcp"] }

Started by a CLI in a tab, it needs nothing else: the tab’s key is already in its environment, so its calls arrive as that tab, counted against that tab’s chain and its permissions. To point it at another running copy – the one being tested rather than the one you are working in – name that copy instead:

{ "command": "<path>\\SHIKISHA-TERM.exe",
"args": ["--mcp", "--pid", "12345", "--token-file", "<its root>\\data\\api-token"] }

--pid is that copy’s process id (the door carries it in its name), and --token-file is where access: "user" leaves its key. --pipe and --token say the same things outright.

Every tool is one command from section 9 with a shikisha_ prefix, so that this app’s send cannot be mistaken for another server’s, and its arguments go in params in the order the command takes them:

{ "name": "shikisha_send_to_tab", "arguments": { "params": ["reviewer", "how is it going?"] } }

The list of tools is the answer to list – asked of the running app, every time, through the same door and the same permissions. What a client is offered and what the app will actually do cannot drift apart, including the part that depends on who is asking. A command a desk switched off for an AI is not on the list an AI is handed.

A command that refuses comes back as a tool that failed, with the reason in it, rather than as a broken connection: the model reads it and tries something else.


  • Join strings with .. (not +)
  • tab.output holds only the latest response, never the earlier conversation
  • Lua patterns are their own thing: %d (digit), %s (space), .- (shortest match). Write %d, not \d
  • When you want to do nothing, write return and it stops right there
  • If you get lost, sprinkle shikisha.log() and read logs/hooks.log

Everything automation can call, in one place. The sections above teach the common ones; this is the complete list.

Whether it may run is decided on each desk’s Automation permissions card

Section titled “Whether it may run is decided on each desk’s Automation permissions card”

The same command can be allowed for you and refused for an AI. The settings card lists every command with two boxes: one for a person, one for an AI.

  • An AI — a call from an AI tab (a tab whose command is an AI: claude, codex, gemini, aider and the like, or a tab talking to a model over an API), and Lua an AI wrote (inside run_scoped)
  • A person — everything else: the hooks and scripts you wrote, the run button, and programs you started yourself

An AI you start by hand inside a terminal tab counts as you. Open a cmd or PowerShell tab, type claude in it, and unticking the AI column will not stop that AI — it is calling with the tab’s own key, and the tab is a terminal. To have it counted as an AI, make the tab’s own command the AI.

Nearly everything is ticked in both columns to begin with. These start out closed to an AI, and each one either steps outside this table, destroys something you own, or speaks for you:

Command Why it starts closed to an AI
lua Runs code with nothing walled off. Open it and the table means nothing
read_path / list_path / write_path / http_raw Raw paths and raw URLs, past the gateways. Allowed folders and hosts are the escape hatch you opened for your own scripts
open_tab A tab runs whatever its line names, so opening one is starting a program – a shell of its own, outside every other row here
close_tab / close_pane Ends the program in a tab / takes away a place you were looking at
restart Throws away the conversation running in a tab
sftp_mkdir / sftp_rename / sftp_rm Changes files on another machine, where there is no undo and no second copy
Every git_ and github_ command Works as the git account or GitHub account you chose, and what it writes is said in that account’s name
reply_url Hands out a link that can type into the tab, to whoever can reach it
ai_ask Costs money and time, and an AI that can ask an AI can do it in a loop

A command that is switched off answers with a sentence saying so, and the refusal is written to logs/hooks.log. Nothing ever fails in silence. shikisha.list() answers for whoever asked, too: it leaves out what that caller may not call.

The table is each desk’s (in the settings screen it is on the desk’s page). Only the rows you changed are written to the config file; anything left standard is not written at all.

// "desks": [ { "name": "…", here ↓ } ]
"automation_permissions": {
"lua": { "ai": true }, // open it to an AI as well
"send_to_tab": { "ai": false } // close it to an AI
}
Command Description
shikisha.send_to_tab(tab, "text") Give a tab an instruction and run it. Works on this tab too (chain +1)
shikisha.ask_tab(tab_id, "line", "text", {timeout_ms=…}) Ask another tab and wait for its reply. line is one short line for the chat, shown in AIConfer (refused when empty, longer than the settings allow, or more than one line); text is everything the tab is sent. Waits while it is busy, sends, and answers {state, reply, round, max_rounds, same_folder, note} once it has finished (reply from its conversation record). When its turn ends the tab is asked once for a line of its own, and its reply is kept as it was. Through the pipe or MCP only
shikisha.say("line") Say one short line to the other tabs, in AIConfer: something said on its own, not the answer to an ask (that line is asked for when the answer ends). Refused when empty, longer than the settings allow, or more than one line. Called from an AI tab
shikisha.react(tab_id, "👍") Mark the last line a tab said in AIConfer: one of 👍 ❤️ 🎉 👀 ✅ ❓. person marks the person’s last line. The same mark again takes it off. Called from an AI tab
shikisha.share("commit"/"pr"/"file"/"url", target, "title") Show the other tabs a card in AIConfer: a commit in this tab’s folder (HEAD, a hash), a pull request’s address, a file inside this tab’s folder, or a web address. Checked first: what does not exist is refused. title is optional (a commit’s subject, a file’s name by default). Called from an AI tab
shikisha.tab_run(tab_id, "command", {timeout_ms=…}) Run a command in a terminal tab and get its output. Only in a terminal the person named with @ in what they last sent you. Waits while the tab is busy, types the command, waits for it to finish, and returns {state, reply} with the output in reply (PENDING at the timeout, the output then typed into your tab). Through the pipe or MCP only
shikisha.browser_do(tab_id, "goal", {timeout_ms=…}) Have a web page driven toward a goal and get what it found. Only a page the person named with @. Runs the page’s 🗣 words run and returns {state, reply, note}: DONE with what the page shows about the goal, STUCK with why, STOPPED when someone stopped it, PENDING at the timeout. One page at a time. Through the pipe or MCP only
shikisha.tab_list() The tabs of this desk, as {id, name, kind (ai / terminal / page), folder, you, named}. named is a tab the person named in what they last sent the caller. Through the pipe or MCP only
shikisha.tab_conversation(tab_id, {want=6, before=…}) Another tab’s conversation, as the phone’s reader shows it. Read from the CLI’s own record – on this PC, or on the server or MicroVM the tab runs on – so a long answer comes back whole, not cut to the screen. Returns {turns = {{who = "you" / "ai", text}, …}, from, more, source}, oldest first: the last want things said (1 to 40). For older ones pass the from you got as before; more is false once the start is reached. A tab whose CLI keeps no record answers source = "none" – use tab_screen. Through the pipe or MCP only
shikisha.send(tab, "text") Raw keystrokes (newline is \r). For answering a prompt, not for instructing
shikisha.draft_to_tab(tab, "text") Leave the text in the tab’s input box without running it — a person finishes and sends
shikisha.note(tab, "text") Write a line on that tab’s screen. For the person watching only: nothing reaches what runs there, and nobody is asked to answer
shikisha.words_note(id, "text", bad) The same act for a page: a line in the strip under it. A browser tab’s screen is the page itself, so there is nowhere to write one otherwise
shikisha.state(tab) The state right now: WAIT / BUSY / DONE / ASK / EXIT
shikisha.wait_state(tab, "DONE", ms) Wait until it reaches that state; true if it did
shikisha.tab_output(tab) Another tab’s latest reply ("" if there is none yet)
shikisha.tab_screen(tab) What is on that tab’s screen right now. The reply is what a turn produced; this is the glass – for a pager, a menu or any full-screen program it is the only output there is
shikisha.tab_read(tab, mark) That tab’s recorded output from mark onward. Returns the text and the next mark, so a long run is followed in pieces without reading the same piece twice. Starts at 0; a tab that is not being recorded reads as "" and gives the mark back
shikisha.restart(tab) Restart that tab, carrying its conversation over. shikisha.restart(tab, "fresh") starts a new one
shikisha.open_tab({ command = "codex", name = "Research" }) Add a tab, written the way the settings write one: command is what runs in it, and name, id and the rest are the settings’ own keys. It goes into the caller’s folder, or into folder when one is given (a folder the desk already has; host, the machine’s name in the settings, says which one when two machines have a folder at that path). Returns { id = ..., uid = ..., folder = ..., host = ... }: the automation name it went in under, drawn when id is left out (two words, calm-otter), who the tab is (uid: never handed to another tab, whatever either is called later), and where it went. An id another tab on the desk already has is refused. Anything addressed to the new id before the tab is up – send_to_tab, show, close_tab – waits for it
shikisha.close_tab(tab) Close a tab, including on a desk that is not displayed. The external API and MCP return success after the close is carried out; a working tab or one awaiting an answer is left open and returns an error. Lua automation on the displayed desk asks the person, the way the tab’s ✕ does

An archived folder is refused before a tab is added. Restore it first or choose an active folder; open_tab does not restore folders automatically.

Hand a question to another AI – the commands in the order you would say them:

local t = shikisha.open_tab({ command = "codex", name = "Research" })
shikisha.send_to_tab(t.id, "Find out why the build is slow") -- waits for the tab to come up
shikisha.wait_state(t.id, "BUSY", 30000)
shikisha.wait_state(t.id, "DONE", 600000)
local answer = shikisha.tab_output(t.id)
shikisha.close_tab(t.id)

There is no one command that opens a tab and says something to it, for the same reason there is none that splits the screen and puts a browser there: every kind of tab is one command, and every way of using it is some order of these.

Command Description
shikisha.show(tab) Put that tab on screen. 0 is the board. Ignored if the person turned Auto-switch off, just moved the view themselves, or is in the settings
shikisha.show_panel(name) Open the right-hand column on one of its panels and switch to it, the way a button that calls a panel up does: files, git, convo, console, picks, ports. A panel the tab in front does not have joins the column with its own ✕ and stays until that is pressed. Held by the same say as show
shikisha.open_result(run) Open that run’s transcript as a result page and go to it
shikisha.split_pane("right") Divide the pane in focus. "right" beside, "down" below. The new half takes focus
shikisha.close_pane() Close the pane in focus. The tab behind it keeps running
shikisha.focus_pane("left") Move focus to the neighbouring pane ("left" "right" "up" "down")
shikisha.equalize_panes() Put every divider back to even halves

Put a browser beside the agent — two commands, in the order you would say them:

shikisha.split_pane("right") -- divide, and the new half takes focus
shikisha.show("br") -- ...so this puts the browser there

There is no one command for that on purpose. split_pane and show each do one thing, and every arrangement anyone wants is some order of the two — a combined “split and open a browser” would only ever be the first arrangement somebody thought of.

Command Description
shikisha.wait(tab, "pattern", ms) Wait until the text appears on that tab’s screen; true if it did
shikisha.sleep(ms) Wait (other tabs keep running)
shikisha.now("%Y-%m-%d") The local date/time, formatted. Sorts chronologically by default — good in file names
shikisha.epoch_ms() Milliseconds since the epoch, as a number, for measuring elapsed time
shikisha.diff(before, after, opts) What changed between two texts, written the way git writes a diff. "" when they are the same. opts is { name = "plan.md", context = 3 }: the name goes on the header lines, and the context is how many unchanged lines are kept either side of a change
shikisha.json_decode(text) A JSON text as a Lua value (objects as tables, arrays numbered from 1). nil, why when it is not JSON – the usual way to read an answer an AI was asked to give as JSON
shikisha.json_encode(value) A Lua value as JSON text: a table numbered 1..n becomes an array, any other table an object

It is handed the two texts and never told where to find them, so the same command serves a reply, a page, a file and a recording:

-- What the AI changed between this answer and the last one
local was = shikisha.get_var("answer") or ""
local d = shikisha.diff(was, now, { name = "answer.md" })
if d ~= "" then shikisha.note(tab, d) end
shikisha.set_var("answer", now)
Command Description
shikisha.get_var("key") / shikisha.set_var("key", value) Remembered variables, shared within the desk
shikisha.log("text") Write a line to logs/hooks.log
shikisha.notify("text") / shikisha.notify("target", "text") Notify Slack / Discord / Telegram, this PC’s own notification area, or a phone that registered itself (only targets you configured). With no target named, it goes to the default one
shikisha.remote_url() The URL a phone can reach this app on, or nil while remote is off. Put it in a notification so “come and help” is one tap away
shikisha.reply_url(tab) / shikisha.reply_url(tab, "target") A link to a page holding that tab’s last answer and a box to reply into it, or nil while remote is off. Each call writes one ticket: it can say something to that one tab and nothing else, it never carries the access token, and it dies at its expiry or when somebody presses disconnect. The second argument names where to report back that the reply landed (default: the primary target). Handing this link out hands out the ability to type into that tab — to anyone who can reach this machine’s private network and read wherever you put it. If that is more than you, they are using your AI account, and most AI subscriptions forbid sharing one; check the terms of your plan
shikisha.t("key") / shikisha.tf("key", {name="..."}) Look up a translated string (tf also substitutes {name}). Used by the built-in orchestrators so they speak the app’s language

By default these are about the tab that called them, which is why no tab is usually named. set_status and set_progress take one as a last argument, to report about another tab. An AI CLI’s own hooks report through here too.

Command What it does
shikisha.set_state("BUSY") This tab says what it is doing rather than leaving it to be read off the screen (BUSY / QUESTION / DONE / WAIT). The second argument is the sender’s clock in milliseconds, so reports that overtake each other still apply in the order they were said
shikisha.set_helper("a1f2", true) This tab’s program says a helper it runs on the side began (true) or ended (false)
shikisha.set_running({"a1f2"}, false) This tab’s program says everything it still has running beside its conversation: helpers by id, and whether anything else is still going. Replaces what was said before
shikisha.set_status("key", "text", tab) Say what is being worked on, in its own words (shown under the tab name). Separate keys let several writers speak without overwriting each other; an empty string clears one. Leave tab out and it is this tab
shikisha.set_progress(0.4, "label", tab) How far along it is (0..1), shown beside the state. nil clears it. Leave tab out and it is this tab
shikisha.set_session("id") This tab says which conversation its CLI is running, so a restart can pick it back up
shikisha.report_prompt("text") This tab says what a person just asked its CLI. A folder with Auto on writes its name and summary from these

A page is addressed by the id you gave it. See “Driving a browser” above.

Command Description
shikisha.browser_open(id, url, profile, private) Open a page. profile names its cookie store; private makes a throwaway one
shikisha.browser_close(id) Close it
shikisha.browser_go(id, "back"/"forward"/"reload"/"to", url) Navigate
shikisha.browser_nav(id, {...}) / shikisha.browser_unnav(id) Show / hide back-forward-reload-address above the page
shikisha.browser_find(id, sel) Is it there? "visible" / "hidden" / "missing"
shikisha.browser_click(id, sel, opts) Click it. opts is { on_missing = "continue" } – answer with the state instead of stopping
shikisha.browser_fill(id, sel, "text", opts) Type into it. Does not submit — follow with browser_press. opts is the same as browser_click’s
shikisha.browser_fill_secret(id, sel, "name") Fill in a registered secret. The value never reaches the script (see below)
shikisha.browser_press(id, "enter") Press a key on the page
shikisha.browser_select(id, sel, "Economy") Choose a value in a native dropdown, by the text a person reads. Its own verb because clicking one opens something the page cannot see into
shikisha.browser_scroll(id, 1, sel) Scroll by screenfuls: a number (negative goes back up), "top" or "bottom". Without sel the page moves; with it, that box does. Also answers how far it actually went, so the end of a list is tellable from the middle
shikisha.browser_settle(id, {ms=1200, expect="quiet"}) Wait for the page to stop reacting to the last move – usually a frame or two, ms is only where waiting stops. expect="options" also waits for a suggestion to appear, for the type-then-choose pattern
shikisha.browser_text(id, sel) The visible text
shikisha.browser_html(id) The whole document
shikisha.browser_digest(id) The operable elements, numbered — what to read before deciding a move
shikisha.browser_elements(id) The same reading as a table: one row per element with ref, role, name, value, choices, section, new and can (which of click / fill / select / scroll apply). For building a question out of a page rather than showing it to somebody
shikisha.browser_fetch(id, url, opts) Request from inside the page (keeps its cookies). Returns {status, ok, url, headers, body}
shikisha.browser_auth(id, "name") Answer basic-auth from a registered secret (as above)
shikisha.browser_state_save(id, "label") Save this page’s login — its cookies and its localStorage — under a name. Returns how many cookies were saved. Sign in once, then a later rally can load it
shikisha.browser_state_load(id, "label") Put a saved login back, so the page is signed in without logging in again
shikisha.browser_snapshot(id, "label") Take a picture of the page (PNG) and save it. Returns the file path — a rally can keep a visual record of what it did
shikisha.browser_ask(id, "text", "label") Put a banner with a button under the page. The app draws it; only a person can press it
shikisha.browser_pressed(id) Has it been pressed?
shikisha.browser_unask(id) Take the banner away
shikisha.browser_wait(id, {ask=..., selector=..., timeout_ms=...}) Wait for whichever comes first. Returns "selector" / "button" / "timeout"
shikisha.browser_devtools(id) Open the page’s DevTools as a page of its own, and return its name and whether it was opened just now (open already, it is left as it is). split_pane("right") divides the pane for it, and show(name) puts it there – from the next turn on, since a page just opened is a tab only then. “Open DevTools beside it” on a page’s tab does the same, and a split written down with DevTools in it gets them back, opened afresh, when the app starts again. Closed to an AI by default: everything the page holds, its cookies included, can be read and changed from it
shikisha.browser_console(id, since) What the page said on its console after line since (leave it out for everything kept): a table per line (seq, level – error, warn, info, log or debug –, from – the page’s code, an uncaught error, or the browser about the page –, text, at, ms), and the number of the newest line to pass as since next time. The first call starts listening, and brings what the page has said since it last loaded; what it said on an earlier page is not kept
shikisha.browser_pick(id, true) Arm picking on a page: until it is put away (false, or Esc on the page), a person pressing a part of the page picks that element instead of pressing it. The same switch as the Picked elements panel
shikisha.browser_picks(id, clear) What has been picked on a page, oldest first: a table per element (n, note, tag, role, name, sel, path, source, box, view, url, style, html), and as a second value the same list written out the way the Picked elements panel hands it to an AI. clear = true empties the list as it is read. Values that look like keys, and the secrets the app holds, are already [hidden] in all of it

Two commands, and between them everything a page can be driven with.

Command What it does
shikisha.ai_choose({ state = ..., questions = {...}, model = "conn/name" }) Hand over a state and a set of questions, each listing the answers it allows, and get one answer per question back: { choice = "CLICK", confidence = 0.9 }. A type = "score" question answers with score, a type = "noul" one with noul (0 to 1). An answer that was not one of the ones offered is refused rather than acted on
shikisha.ai_text({ prompt = "...", system = "...", shape = {...}, model = "conn/name" }) Ask for words: what to type in a field, what a page amounts to. shape is a JSON Schema, and with it the answer comes back in that shape instead of as a paragraph

model is connection/model, the same spelling a model tab’s command line uses. Left out, the settings decide (the browser tab’s Decision model and Conversation model, and for whatever it leaves unset, the Deciding AI under AI agents), and where nothing is chosen there, the assistant AI answers.

An AI installed on this PC answers too, on the plan it is signed in with and with no key: model = "@claude", "@codex" or "@gemini", and with a model of its own after a slash ("@claude/haiku"). It is asked with no tools, in an empty folder of its own, and answers the way a conversational model does.

The app keeps running while a model thinks, in a hook or a quick action: both stop where they asked, the way sleep and ai_ask do, and other tabs and the screen carry on. So does browser_settle there. Lua an AI writes for a page runs straight through instead, and waits where it stands.

Two very different services answer ai_choose, and the answer is the same shape either way. One is built for it: told what the allowed answers are, it returns one of them with a probability for each, in a fraction of the time a sentence takes to write. The other is an ordinary conversational model, told the same thing in words and held to the same shape. That is what lets a page be driven by whoever has only the ordinary kind – slower, and otherwise the same. Set which a connection is under its own settings (Answers).

A password or a token is registered under Secrets on the desk’s settings page. A script writes the name it was given and never receives the value.

shikisha.browser_fill_secret("br", "#password", "github")
  • The name means something inside that desk only. Another desk’s secrets, and the ones the program keeps for itself (an SSH password, say), cannot be reached by naming them
  • Registering one asks where the secret may be used. It is filled in on those pages and nowhere else, and stops being filled the moment the page goes somewhere else
  • Addresses are written out in full: https://example.com is that whole site, https://example.com/api only the pages under /api, and https://*.example.com the site and every subdomain. Anything after ? is not looked at
  • An http:// address reaches a machine that cannot prove who it is, so registering one also asks for “Allow unencrypted connections, at my own risk”
  • Who may use it is two answers, a person and an AI, and starts as the first alone. Tick the AI only for the secrets a script that an AI’s turn set going should be able to use. Ticking the AI alone is allowed too, and then a script somebody ran by hand cannot reach it

The files where an SSH tab is connected. Which machine is said by naming the tab that is connected to it, the same way the git commands are told a tab. A path over there is the far end’s; a path here is this machine’s.

Both ends are fenced by what that tab was given. A path on this machine has to be inside the tab’s working folder, and a path over there inside the folder the tab was given on that machine, if it was given one. .. does not get out of either. So a command told one tab cannot reach a file that tab was never handed – which is the same promise read_file keeps, kept here too.

None of them stop the app while they run. A transfer takes as long as the link takes; the command hands the work over and waits, and every tab on screen carries on. So a loop that sends a folder a file at a time is a loop somebody can watch.

Command Description
shikisha.sftp_ls(tab, "public/") A listing: {name, dir, size, modified} each. Folders first, then by name
shikisha.sftp_ls_here(tab, "dist/") The same, on this machine’s side of that tab – so a walk written for one side reads the same written for the other
shikisha.sftp_stat(tab, "public/index.html") One of them, or nil if it is not there
shikisha.sftp_get(tab, "there", "here", opts) Bring a file here. opts is { overwrite = true } (a file that is already here is not replaced otherwise)
shikisha.sftp_read(tab, "public/index.html") The file itself, as a string, without leaving a copy here
shikisha.sftp_put(tab, "here", "there", opts) Send one. opts is { overwrite = true } (a file that is already there is not replaced otherwise)
shikisha.sftp_mkdir(tab, "public/img") Make a folder
shikisha.sftp_rename(tab, "a.txt", "b.txt") Rename or move
shikisha.sftp_rm(tab, "b.txt") Delete. A file, or a folder with nothing in it

sftp_read is what shikisha.diff is usually handed: read the copy over there, compare it with the one here, and a script can say what a send would change before anything is sent.

There is no “send the whole folder”. Write it as sftp_ls_here and sftp_put in a loop going out, sftp_ls and sftp_get coming back. The panel’s own folder button is that loop, written as a template rather than built in – deepest first, biggest first, skip what matches, stop on the first refusal are all arrangements somebody might want, and a command would be one of them. One command for it could only ever be the first arrangement somebody thought of – the same reason split_pane and show stayed two.

Deleting, making and renaming are for people by default (automation permissions). Open the reading ones (sftp_ls / sftp_get / sftp_read) and sftp_put to an AI first, if any.

The same on a screen. A tab whose command is sftp://deploy@example.com:22 is the file panel: two lists of files, this tab’s working folder on the left and that server on the right. It has no way of moving a file that is not one of the commands above, and it asks the same permission table – so what you can do by hand and what a script may do cannot come apart.

The panel is a connection, addressed exactly the way a terminal on another machine is, so it is a tab the file commands can be told: sftp_put("that name", "dist/a.txt", "public/a.txt") sends to the server the screen is showing. A terminal tab written to the same address shares the connection with it; nobody has to say so.

Orchestration: handing work between AI tabs

Section titled “Orchestration: handing work between AI tabs”

A job one AI tab (the lead) hands out to others and sees through (see “Orchestration” in chapter 7). Answered through the pipe or MCP only; each answer carries next, what to do next (the command, wherever there is one); a refusal says why, and names the command that puts it right when there is one. Jobs are j1, tasks t1, assignments a1, questions q1, decisions d1.

Command Description
shikisha.job_open("the whole job") Start a job led by the calling tab. Refused to a worker already as deep as Settings > Limits on handing work allows
shikisha.job_status({job="j1"}) Where a job stands: its tasks, who is on them, questions and decisions waiting, loose ends, and next
shikisha.job_close("what was done", {job="j1"}) Close a job. Refused while anything is left open (a task not done and not dropped, an assignment at work, a tab nobody let go or kept, an unanswered question, an open decision, unread mail); the refusal lists each with its command
shikisha.task_add("the task", {waits_on={"t1"}, title=…}) Add a task. Open at once, or once every task in waits_on is done. Write it to be read alone: where, what to end up with, what must not change, and how to tell it is done
shikisha.task_list({open=true, short=true}) A job’s tasks. open keeps only those that can be assigned; short cuts each to 120 characters
shikisha.task_drop("t1", "why it is not needed") Take a task out of the job on purpose, so the job can close without it. Not one being worked on (stop it first) or already done. A failed or stopped task is tried again by assigning it again instead
shikisha.assign("t1", "tab_id") Hand a task to an AI tab. Only a tab the person named with @ or that this job opened. Waits while the tab is busy (up to 45 seconds), types the brief in (after one typed line, for a CLI that needs it), and answers once the tab has started on it
shikisha.report("done" or "failed", "what it did", "what it found", "what is left", {files={…}, report_path=…}) A worker reports its assignment, once. Only from the tab, and the run of its program, that was given it
shikisha.ask_lead("question", {choices={…}, resume="q1"}) A worker asks its lead and waits for the answer. If the wait runs out the question stays asked; resume waits for the same one again
shikisha.answer("q1", "answer") The lead answers a question
shikisha.tell("a1" or "workers" or "workers:idle" or "workers:codex", "text") Say something to one worker, all of them, the idle ones or one CLI’s; a worker’s goes to its lead. Read at the next inbox
shikisha.inbox({wait=true, dealt=12, kinds={…}, peek=true}) Read the mail: the lead its job’s, a worker its assignment’s, any other tab its own. Hands over the oldest mail not yet dealt with (up to 25), the same until dealt names that handover. wait waits for some (95 s unless wait_ms); kinds names which kinds wake it
shikisha.decision_open("t1", "question", {choices={…}, to="person"}) Hold a task for a decision. to="person" notifies the person and shows the choices on the job’s panel; the decision arrives in the lead’s mail
shikisha.decision_make("d1", "choice") The lead makes one of its own decisions
shikisha.let_go("a1") Let a worker’s tab go once its assignment is over: closed if this job opened it and nobody has typed into it, kept otherwise (the answer says why)
shikisha.keep("a1") Keep a worker’s tab once its assignment is over
shikisha.stop("a1" or "all") Stop a worker: Esc now, and a tab the job opened is closed if it has not stopped 15 seconds later. The task is held: assigning it again goes on with it, task_drop takes it out
shikisha.open_ai_tab("claude" or "codex" or "gemini", {folder=…, name=…}) Open a new AI tab running that CLI, set up the way every new AI tab is (Yolo only if the person’s setting says so). Open to an AI where open_tab is not: what starts is never a command the caller wrote
shikisha.worktree_add("branch", {base=…}) Make a working folder (git worktree) for a branch, placed by the project’s worktree rules, and put it on the desk. Answers the folder

How the rally works: files in and out, plus a judge. You can build your own the same way.

Command Description
shikisha.contract() The promises a tab is asked to keep while it holds a turn: say it in words rather than opening a confirmation prompt, report once, say what you did and what is left, then wait. Send it with the opening instruction, not every turn
shikisha.exchange_new() Make a folder for this run and return its path
shikisha.exchange_write(path, "text") Write a file (overwrites)
shikisha.exchange_append(path, "text") Append to one
shikisha.exchange_take(path) Read it, delete it, return it. nil if absent — this is the hand-over
shikisha.ai_ask("what you want") Ask the assistant AI from Settings > AI agents and get the answer as text; nil and a reason when there is none. The app keeps running while it thinks (the same machinery as sleep: other tabs and the screen carry on). Three minutes by default, {timeout_ms=…} to change it. {light=true} asks for a short answer as cheaply as the AI can give one (its smallest model where it has a choice, no tools, no long instructions); for anything more than a line or two, leave it off. {ai="codex"} asks a different assistant AI, and {ai="model deepseek/deepseek-chat"} one of the registered AI providers
shikisha.lint(code) Compile-check Lua without running it. An error string, or nil if sound
shikisha.run_scoped(id, code) Run AI-written Lua against one page, in a jail: no files, no network, no other tabs. Returns err, out
shikisha.lua(code) Run a whole chunk with everything in reach — loops, branches, several commands at once. Returns err (nil when it ran) followed by whatever the chunk returned. The unwalled twin of run_scoped, so never hand it code you didn’t write
shikisha.list() The commands the caller may run, by name (see automation permissions above). Read off the table itself, so it is never out of date
shikisha.record(text) / shikisha.record_reset() Keep a pasteable record of the run
shikisha.take_replay() Drain the replay journal — the durable spelling of every operation since the last drain
shikisha.set_result(code, "reason") The run’s verdict. Written to data/last-result.json and shown on screen
shikisha.skip("reason") Stop this run here and say so: one line on the tab’s screen and in the log. For when an automation decides there is nothing to do

Which repository is named by the tab sitting in it. Leave the tab out and it is the tab that called. No path is accepted.

The reading side launches git. The branch shown in the sidebar comes from somewhere else – that path never launches git, which is why it still answers during a rebase.

Command What it does
shikisha.git_status(tab) The changed files, one row each: {path, index, work, staged, unstaged, conflict, from}. index and work are git’s own two letters (staged side, working-tree side). staged and unstaged are not opposites — stage one hunk of a file and both are true
shikisha.git_diff(tab, {path=…, staged=…, encoding=…}) The diff, as text. staged=true reads the staged side; path narrows it to one file. Each file’s lines are read in the encoding they are saved in (UTF-8, Shift_JIS, EUC-JP…); encoding says which instead
shikisha.git_log(tab, count) Recent commits: {hash, short, author, date, subject}. 20 by default
shikisha.git_conflicts(tab) Just the paths of the files with a conflict
shikisha.git_branch(tab) The branch: {name, protected, upstream, ahead, behind, base, base_behind, catch_up, catching_up}. protected marks one this folder guards, so committing straight onto it is worth asking about. upstream is the branch on the server this one is counted against (origin/main), ahead the commits here not there yet and behind the other way round, as of the last fetch – all three absent when there is no such branch at all. It is the branch this one follows, else the branch of its own name on the server it would push to, and by_name is true for that second case: the work is there, the branch is simply not set to follow it (a push from a terminal with no --set-upstream). base is what the branch was cut from when that is written down, base_behind how many commits it is behind that base as of the last fetch, catch_up the commands bringing its latest in would run, and catching_up that base’s name while a merge of it has stopped half done. nil when the head is detached
shikisha.git_graph(tab, {all=…, remotes=…, count=…}) The history: {graph, hash, short, author, date, subject}. graph is git’s own drawing, and the rows with no commit on them (a merge closing) are kept
shikisha.git_detail(tab, hash) One commit in full: {hash, parents, author, author_date, committer, commit_date, subject, body, files}
shikisha.git_branches(tab) Every branch: {name, current, protected}
shikisha.git_checkout(tab, "name") Move onto that branch
shikisha.git_merge(tab, "name") Bring that branch in. A conflict stops it, and shows up in git_conflicts
shikisha.git_catch_up(tab, "origin/main") Bring the latest of a base in: fetch that one branch from its server, then merge what was fetched (never the local copy). Refused while anything tracked is uncommitted; a conflict stops it where the merge stopped, with the files in git_conflicts. Answers {taken}, the number of commits that came in (0 when there was nothing new). A third argument names the branch as it was pushed (git_catch_up(tab, "origin/main", "feature")): it is fetched first, and a folder behind it is refused before anything is merged – the way a pull request’s conflict is settled
shikisha.git_set_base(tab, "origin/develop") Write down what the branch in front was cut from, so bringing its latest in knows where from. A worktree made in the app has it written already
shikisha.git_remote_branches(tab) The branches the servers have, as last fetched: {name, catch_up}, where catch_up is the commands git_catch_up would run for that base
shikisha.git_fetch(tab) / shikisha.git_pull(tab) / shikisha.git_push(tab) Talk to the server. Everything else waits until it answers (up to three minutes). git_push sets the upstream and retries when the branch has never been sent, and says so in its answer. They sign in as the git account chosen for the tab (see below), and refuse to run where none is chosen
shikisha.git_hunks(tab, {path=…, staged=…, commit=…, encoding=…}) The diff cut into hunks: {file, header, start, end, patch, encoding, exact}. Each patch is a whole patch on its own. encoding is what the file’s lines were read as; exact is false when they could not be read in it without loss, and such a patch is refused by git_apply
shikisha.git_apply(tab, patch, {cached=…, reverse=…, encoding=…}) Apply a patch. cached puts it in the next commit, reverse takes it back out. Pass the hunk’s encoding so its lines go back as the file’s own bytes (UTF-8 when left out). Staging one hunk is these two together
shikisha.git_stage(tab, paths) Add to the next commit. One path as a string, or several in a table
shikisha.git_unstage(tab, paths) Take back out of the next commit
shikisha.git_discard(tab, paths, {staged=…, plan=…}) Throw the change to those files away. Without staged, what goes is the half that is not in the next commit, and a file git has never seen is taken off the disk; with it, the files go back to the way the last commit has them, and one the last commit does not have comes out of the next commit and off the disk. Files in conflict are left alone. Nothing here can be undone – what it throws away was never committed. plan=true answers without running anything, so a screen can show what would run before it asks. Either way the answer is {plan, said}, where said is those commands as a person would type them
shikisha.git_branch_create(tab, "name") Make a branch and move onto it. Staged work moves with you, which is what makes this the way out of a refusal on a shared branch
shikisha.git_commit(tab, "message", opts) Commit what was added and answer with the short hash. It stops on a protected branch – make a branch, or pass {allow_protected=true} to say you meant it. Which branches those are comes from Settings > Protected branches (main and master until somebody says otherwise), and each working folder may name its own
shikisha.git_run(tab, "args…") Run any git and answer with its output. No shell is involved: ; and && arrive as arguments and git refuses them. It signs in as the chosen git account, and where none is chosen, the way git on this PC already does

Which account signs in. Nothing chosen is git on this PC as it already is – its credential helper and keys – which is what most people have and never think about. A git tab uses the account chosen on its own page; any other tab uses the one its folder’s project chose, on the project’s page. The accounts themselves are the app’s (Settings > GitHub / Git): a token over HTTPS or an SSH key file, and the name and email its commits carry, which git_commit and git_merge use too. Beside them the same page lists the GitHub sign-ins git on this PC and GitHub CLI (gh) hold, each of which a project can choose by name (@pc:<login>) – what a PC holding two GitHub accounts needs, since git cannot tell which to use. When a fetch, pull or push is refused as the account, the git column says so with a button to that page. A git typed in a terminal tab signs in as the same account: the tab is started with the settings for it (GIT_CONFIG_*, and the credential helper under that account’s own server), so anything running in that tab, an AI included, signs in as it too. Nothing is taken away – a repository on another server goes on signing in the way this machine already does – and a tab picks a change up the next time it opens.

All of these are open to a person only, to begin with (automation permissions). If an AI is to be let in, git_status / git_diff / git_log are the place to start. Opening git_run is the same as handing it all of git.

The issues and pull requests of the repository a tab works in, signed in as the git account that tab’s project chose (a git tab’s own). Each waits for GitHub, like the git commands that talk to a server. A table comes back; the Issues tab in the list shows the same answers.

Command What it does
shikisha.github_issues(tab, {state=…, mine=…, text=…, page=…}) One page of issues: {repo, total, page, per_page, items}, each item {kind, number, title, state, reason, author, labels, assignees, comments, updated, url, workspace}. state is open (default), closed or all; mine is issues assigned to the account; text is GitHub’s own search words (label:bug)
shikisha.github_prs(tab, {state=…, mine=…, review=…, text=…, page=…}) The same for pull requests. state can also be merged; mine is ones the account opened; review is ones waiting for its review. An item carries draft too
shikisha.github_issue(tab, number) One issue in full: the row above plus body, created, comments ({author, bot, body, created, url}) and events ({kind, actor, subject, reason, created})
shikisha.github_pr(tab, number) One pull request in full, as above plus head, base, fork, merged, mergeable, merge_state, additions, deletions, changed_files, reviewers, review (approved / changes_requested / empty) and checks ({failed, pending, passed, total, items})
shikisha.github_labels(tab) / shikisha.github_assignees(tab) The labels an issue can have, and the logins it can be assigned to
shikisha.github_issue_create(tab, {title=…, body=…, labels=…, assignees=…}) Open an issue. Answers {number, url}
shikisha.github_pr_create(tab, {title=…, body=…, head=…, base=…, draft=…}) Open a pull request from the branch head into base, as a draft when draft = true. Answers {number, url}
shikisha.github_comment(tab, number, "text") Comment on an issue or a pull request. Answers {id, url}
shikisha.github_issue_state(tab, number, state, {duplicate_of=…}) open, completed, not_planned, or duplicate (with duplicate_of, which also posts “Duplicate of #n”)
shikisha.github_pr_state(tab, number, "open" or "closed") Close a pull request without merging it, or open it again
shikisha.github_pr_merge(tab, number, method) Merge: squash (default), merge or rebase. The branch is left where it is

Off unless you register a gateway — see section 6.

Command Description
shikisha.read_file(name, rel) / shikisha.write_file(name, rel, data) Through a registered file gateway
shikisha.list_files(name, rel) What is in that folder, one level: {name, dir, size, modified} each, folders first and then by name – the shape sftp_ls answers in, so a walk written for one side reads the same on the other
shikisha.http(name, body) Through a registered HTTP gateway
shikisha.read_path(p) / shikisha.write_path(p, data) / shikisha.list_path(p) / shikisha.http_raw(url, body) Raw path / raw URL. Always fails unless allow_dirs / allow_hosts says otherwise