Each builtin is a web API at //bin/NAME, and the editor of each FIFO has a terminal for the same shell. The terminal became the form of /bin/sh, and gzip and gunzip came as builtins. Then an assistant got the same shell, through the MCP endpoint /mcp.
A terminal in the editor
The editor of each FIFO has a terminal now. It is hidden: Ctrl and the backquote key open it, as in many code editors, and so does ?terminal after the address of the editor. A phone has no Ctrl key, so three quick taps on the name of the FIFO open it there.
Why a terminal
The builtins have the names and the options of the commands of a shell, and I check them against the real tools. But to try jq on a body, I had to save an envelope, send a Request, and read its report. The terminal runs one line at once. It uses the parser of the envelopes, so a line that works there works in $( ) of an envelope.
body='{"id": 7, "price": 1.50}'
$body | jq -c '{id, price}'
printf '%s\n' b a c | sort
A variable is a capture: name=WORD gives it a value, and a variable with no value is an error, as in an envelope. A value in single quotes can have more lines, so a sample body goes in as it is: Enter in an open single quote adds a line, as the second prompt of a shell. The field and the lines have the colors of a shell, from the font FIFO Code.
My first idea was the restricted :shell of Erlang. It reads Erlang, not this syntax, and each name that it reads becomes an atom. The VM never collects atoms, so a visitor could fill their table and stop the VM. The parser of the envelopes makes no atoms.
What a shell has and a FIFO does not
Redirections need files, and mkfifo.com has only FIFOs. A proposal is in the list of ideas (2>&1, and > fifo as a write into a named pipe), and for now the FIFO syntax has none. But a person who knows a shell writes jq . < body.json from habit, and in an envelope < is only a character. Thus the terminal reads <, >, ;, & and backquotes out of quotes as errors, and the error tells the pipe as the way. A line does not do a different thing with no word about it.
The home of the terminal
A builtin gets the home of its envelope: totp puts it in the key of its codes, and hmac opens a sealed key only in its own FIFO. First I gave the terminal the FIFO of the editor as its home. Then I saw the problem: the terminal of the locked editor of /auth could make the sign-in code of each address, and hmac could sign with a sealed key that a person can change but cannot read. Now the home is @terminal/ and the key of the client address. A FIFO cannot have @ in its name, so the codes and the seals of a FIFO never open in the terminal.
The terminal sends nothing to a FIFO, and tee is an error: "Try" sends Requests. Each line has the work of one Request (1024 builtins, 10,000,000 steps), and one address can run 60 lines in a minute.
On a phone
The dialog fills the screen. A phone keyboard hides |, $ and the quotes, so a row of keys has them, and the earlier lines. iOS does not make 100dvh smaller when the keyboard opens, so the hook gives the dialog the height of visualViewport, and the field stays above the keyboard.
Nothing is stored. The variables stay in the process of the page, the earlier lines in the memory of the page, and no line goes to the log. With no JavaScript, the form sends each line, and the variables come back in the form.
The builtins on the web
Each builtin of mkfifo.com is now a web API. https://mkfifo.com//bin/jq is jq: the body of the Request is its input, and the query gives its words, between &.
curl --data-binary '{"items": [1, 2]}' 'https://mkfifo.com//bin/jq?-c&.items'
printf 'b\na\n' | curl --data-binary @- https://mkfifo.com//bin/sort
curl --data-binary 'echo hello | tr a-z A-Z' https://mkfifo.com//bin/sh
Two slashes
The name of a FIFO has no /, so the names with a / were free for mkfifo.com. I took the names of a file system: /bin/NAME for each builtin, and /dev/null, which drops each Request. The address has two slashes: the domain, then the name. Plug drops the empty part between the two slashes, so the router reads the path in two parts. A browser reads the link href="//bin/jq" as the host bin, so a link of a page has the name only, /bin/jq.
GET of each one is a page with a curl command, and the answer that the builtin gives now. An envelope can send to them too: $body | /bin/sort, tee /dev/null, and To: /bin/sh in an egress envelope. With Reply-To: -, the output of the script goes to the client.
sh and eval
sh runs a script with the syntax of an envelope, and eval runs its words as a script. A line break, ; and && end a command, name=WORD gives a value to a variable, and body=$(cat) keeps the input. echo is new too, as echo of GNU coreutils.
#!/bin/sh
set -eu
body=$(cat)
id=$($body | jq .id)
echo "order $id" | tr a-z A-Z
Each person can use eval, also on the text of a Request. A script has the limits of its Request: 1024 builtins, 64 messages and 8 hops. Its home is @sh, not the FIFO of the envelope. No FIFO has @ in its name, so totp and hmac cannot use the codes and the sealed keys of the FIFO in a script.
The FIFO syntax is not the syntax of a shell in all things. Where a script of a shell would do a different thing here, the shell gives an error: $1, $HOME, ||, a redirection. A script stops at its first error, as sh -e, so ; and && do the same. The terminal of the editor runs its lines with this shell now.
The line breaks
Most builtins dropped the line break at the end of their output: jq .id gave 7, but jq writes 7 and a line break. In an envelope, $( ) removes it, so this did not show. In a script, it showed: the lines jq .id and jq -r .name gave 7Ann Lee. Now each builtin gives the bytes of its tool, and a value in an envelope still has no line break at its end.
The check with the TLA+ models now compares these bytes too. It found one more difference: sed q writes a line break after a last line that has none. A test of the bytes found a second one: wc -l counted a last line with no line break, but wc of GNU counts the line breaks. I fixed both.
The terminal is the form of /bin/sh
The terminal of the editor and //bin/sh ran the same shell, but with two sets of rules. The terminal sent nothing to a FIFO, its home was a key of the client address, and it had its own rate limit. A script of //bin/sh from curl could send to a FIFO, its home was ~, and it counted as a Request. Thus a line could work in one and fail in the other. Now the terminal is the form of /bin/sh, and each line is a Request to it.
?form
On the page of a FIFO, ?form opens its form: the letter of "Try". /bin/sh is a virtual FIFO, and its form is the terminal:
https://mkfifo.com//bin/sh?form
The address shows the page of /bin/sh with the terminal open, and the page of /bin/sh has a link to it. ?terminal on the editor goes away. In the editor of each FIFO, Ctrl and the backquote key, or three quick taps on the name of the FIFO, still open the same terminal.
Each line is a Request
A line has what a script of //bin/sh has: the home ~, 1024 builtins, 64 messages and 8 hops. It counts in the rate limit of /bin/sh, as a POST from curl. Thus a pipe to a FIFO, tee and a redirection send real Requests now:
order='{"id": 7}'
$order | orders # the answer of the FIFO orders
$order > audit # a Request to audit, with no output
jq .id < status # the answer of a GET of the FIFO status
In the editor of orders, the line $order | orders shows in the list "Requests", as a Request from curl does. Before, "Try" was the only way from the editor to send a Request. The terminal does not replace it: "Try" sends the letter of the ingress envelopes, with its fields.
The variables
A Request to /bin/sh keeps nothing, but the terminal has variables: body='{...}' in one line, and $body in the next one. The form keeps them, and each line runs with them, as if the lines name='VALUE' of set came before it. With JavaScript, the process of the page keeps them. With no JavaScript, the form has a hidden field for each one, and sends POST //bin/sh?form. A POST with no valid CSRF token is not a line of the terminal: it is a Request to /bin/sh with the word form.
The home
The home of the terminal was @terminal/ and a key of the client address, so that totp gave codes that only the terminal of that address accepted. Now it is ~, the home of /bin/sh. A code of the terminal is a code of //bin/totp too. No FIFO has ~ in its name, so the codes and the sealed keys of a FIFO still never open in the terminal.
How
One function, FIFO.env/1, now makes the environment of a builtin: the home, the time, the sends of a pipe, of tee and of the redirections, the work, and one budget of messages. A builtin of an envelope, //bin/sh and the terminal use it. The page of the terminal and the editor run the lines with the same code (an attach_hook of LiveView), so the two terminals are one.
gzip and gunzip on the web
gzip and gunzip are builtins now, so they are web APIs too:
printf 'hello\n' | curl --data-binary @- https://mkfifo.com//bin/gzip > hello.gz
curl --data-binary @hello.gz https://mkfifo.com//bin/gunzip
In an envelope and in the terminal, they work as in a shell: $body | gzip | base64, and printf hello | gzip | gunzip.
The bytes of GNU gzip
Erlang has zlib, and zlib comes from the same authors as GNU gzip. With the window bits of zlib for the gzip format, the header is the header that GNU gzip writes for the input of a shell: no name, the time 0, the level of compression, and the system Unix. For hello and for each input up to 8 KiB in the tests, the bytes are the bytes of GNU gzip 1.12. For 100 KB of random bytes they are different. GNU gzip reads the bytes of gzip, and gunzip reads the bytes of GNU gzip, and the tests check both when the computer has GNU gzip. The VM of the Worker, in WebAssembly, has zlib too: in wrangler dev, //bin/gzip gave the bytes of GNU gzip for hello, and the two programs read the 270 KB of each other.
Where a member ends
A gzip file can have more members, one after the other: (gzip a; gzip b) is a file, and gunzip gives ab. For each member, gunzip must know where its compressed data end, because the CRC and the length of the data come after them. zlib does not tell it to Erlang: safeInflate, which gives the output in small parts, drops the bytes after the end. Also, it says :finished when its input ends, not when the data end. Only inflateEnd tells if the data ended.
So gunzip reads the header itself, decompresses the data, and computes the trailer that it expects: the CRC-32 and the length. Then it looks for these 8 bytes in the input. The end is the first place where zlib finds the end of the data, and does not find it one byte before. When no place agrees, a search by halves finds the end, and the bytes there tell the error: a wrong CRC, a wrong length, or an input that ends too soon. The messages are the messages of GNU gzip, for example gzip: stdin: invalid compressed data--crc error.
A small input, a large output
1 MB of zeros becomes about 1 KB of gzip. Thus gunzip stops at 1 MiB of output, the limit of each builtin, and zlib gives its output in small parts, so the memory stays small. Each pass of zlib also counts in the steps of the Request, as the matches of grep do: a file of thousands of empty members, or data with many copies of their trailer, cannot make a long work. gzip counts a step for each byte of its input, so a Request cannot compress its 16 MiB of work at level 9.
Bytes in a pipe
The output of gzip is not text. When a pipe gives it to a FIFO, the Request now has Content-Type: application/octet-stream, and //bin/gzip answers with the same type. The page of //bin/gzip shows its answer in hexadecimal. A body line of an ingress envelope does not agree with bytes, as before: a capture of bytes would go to places that expect text, such as the escapes of JSON and the header fields of an email. That needs its own work.
The shell for an assistant
An assistant that speaks the Model Context Protocol (MCP) can use the same shell now. https://mkfifo.com/mcp is an MCP endpoint with one tool, run: it takes a script, runs it with eval, and gives the output. In Claude Code:
claude mcp add --transport http mkfifo https://mkfifo.com/mcp
So an assistant can try a jq filter on real JSON, compute a sha256sum, or send a Request to a FIFO with a pipe and read the answer, with no account and no key.
A FIFO, not a route
The endpoint is not code in the router. It is a FIFO of the site, mcp, with seeded envelopes, like /contact and /auth. Each method of the protocol is one ingress envelope, and so is each error. Selector lines read the JSON-RPC message: $.method: ^tools/call$ and $.params.name: ^run$ pick the envelope, and $.params.arguments.script captures the script. The answer egress writes the reply with jq, and the text of the result is $(eval $script 2>&1). An error of the script starts with eval:, so isError is startswith("eval: ").
Thus the editor of /mcp is the whole server: 27 ingress envelopes and their answers. Anyone can read them, and nobody can change them. A test sends a request to each one, and checks that exactly one envelope agrees with each request.
Two versions of the protocol
The version 2026-07-28 of MCP has no session and no initialize. Each request tells its version and the capabilities of the client in _meta, and it repeats the method and the name of the tool in the header fields Mcp-Method and Mcp-Name, so that a proxy can route it without the body. That fits a FIFO well. An envelope examines a header field and a JSON value in the same way, and a capture of one can go in the pattern of the other: the envelope of a header field that does not match the body has lines like Mcp-Method: ^(?<header_method>.*)$ and $.method: ^(?!$header_method$).+$.
Many clients still speak the older versions, which start with initialize. The endpoint takes them too: one envelope answers initialize, and tools/list, tools/call and ping have a second envelope with no rules for _meta. A request with no version header is of version 2025-03-26, as the protocol says. I checked the official TypeScript SDK in four ways: the client of version 2 pinned to 2026-07-28, the same client in its automatic mode, its default mode (the older versions), and the client of version 1.
A browser sends Origin with a request from a page. The endpoint answers 403 to each origin that is not mkfifo.com, so a page of a different site cannot use the tool from the browser of its visitor.
A GET that takes no HTML
A client of the older versions can open a stream with a GET and Accept: text/event-stream. The endpoint has no stream, so the protocol asks for 405. But GET /mcp is the editor of the FIFO, and the editor is HTML: Phoenix answered 406 before an envelope could see the request.
Now a GET that takes no HTML is a Request when an envelope of the FIFO agrees with it, as the GET of a held FIFO. It still gets 406 when no envelope agrees. A browser takes HTML, so a link still opens the editor. And the rule gives an envelope no request that it could not get before: POST /NAME?_method=GET already agreed with each envelope for a GET.
The limit of the site
The FIFOs of the site have a low rate limit, 20 Requests in 10 minutes from one address, because they are letters to the team. An assistant sends a few requests for each step, so my first test with a real client hit that limit in a few minutes. A Request to mcp now counts only in the limit of its own name, as /bin/sh does: 120 in one minute.