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 a short word (panda, 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.

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.send(tab, "text") Send raw keystrokes (newline is \r). For answering prompts, not for instructions
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_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 / reload_hard (fetch it all again) / url
shikisha.browser_unnav(page.id) -- take it away

Back and forward grey out when there is nowhere to go. The address box only opens http/https. The same four 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, an operate rally). 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. During an operate rally, every executed op is rewritten in a durable form and appended to the run’s replay.lua: 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)

Download replay.lua from the “⬇ Replay Lua” button beside the 🎯 target dropdown, or from the same button at the top right of the result view that opens when an operation 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 stopCtrl+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, model connections 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, no connections, the built-in git settings). Work’s repositories beside your own on one machine never share an API key or a chat.

"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
"providers": {
"work-azure": { "base_url": "https://….openai.azure.com/…", "api_key": "@provider/work/work-azure" }
},
"git": { "protect": ["main", "release/*"] },
"git_accounts": [
{ "name": "work", "login": "me-at-work", "user_name": "Me", "user_email": "[email protected]", "owners": ["my-company"] },
{ "name": "home", "method": "ssh", "key": "C:/Users/me/.ssh/id_home" }
],
"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.

A git account’s token is not written here either: it is filed under git/<desk id>/<account name> when it is entered on the desk’s Git accounts page. Nothing picks an account on its own. A project names the one the git column beside its folders signs in with (git_account), a git tab names its own, and "@pc" means the way git on this PC already signs in. 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.

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. Six commands start out closed to an AI, and each one either steps outside this table or destroys something you own:

Command Why it starts closed to an AI
lua Runs code with nothing walled off. Open it and the table means nothing
read_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
close_pane Takes away a place you were looking at
restart Throws away the conversation running in a tab

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.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.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
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.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_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_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_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"

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://[email protected]: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.

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 > Basic 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 this desk’s model connections
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 it follows (origin/main), ahead the commits here not there yet and behind the other way round, as of the last fetch – all three absent when it follows nothing. 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_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 it has no credentials at all

Which account signs in. A git tab uses the account chosen on its own page. Any other tab uses the one its folder’s project chose (the project’s page, or the menu at the top of the git column). The accounts themselves are the desk’s own (desk settings > Git accounts): 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. “This PC’s git settings” is a choice like any other – git’s own credential helper and keys. 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