6.10. Output actions

One typed function usually wants more than one presentation. sift scan returns [Hit], which is what a downstream program should get, and not at all what a person at a terminal wants to read. The obvious fix — export a second command that formats the first one’s output — doubles the module’s surface and puts presentation into the library.

An output action attaches a formatter to a command as a flag. The command keeps its type; the flag routes the result through a named term on the way out.

--' Print one `path:line:text` record per line
asLines :: [Hit] -> Str

--' Report the number of matches instead of the matches
countHits :: [Hit] -> U64
countHits = size

--' Search a directory tree for lines containing a pattern
--' @with   -c/--count=countHits
--' @render -p/--plain=asLines
scan :: ...

The directive names the flag and the term: @with -c/--count=countHits means "the flag -c or --count routes the result through `countHits`". The term’s own docstring becomes the flag’s help text, which is why it is worth giving formatters docstrings even when they are one-liners:

$ ./sift scan -h
...
General Options:
  -h, --help   Print help; -hh adds details and examples, -hhh adds schemas
               (nexus options: -h @)
  -c, --count  Report the number of matches instead of the matches
  -p, --plain  Print one `path:line:text` record per line
...
$ ./sift scan the notes -c
4

A formatter is an ordinary typechecked function A → B where A unifies with the command’s return payload. countHits is [Hit] → U64, so the composed command returns a U64. size from the standard library would have done as well; countHits exists only to carry the docstring.

Because each flag produces a different type, -h reports the return as a table instead of a single line:

Return:
  default:    [Hit]
  -c/--count: U64
  -p/--plain: Str
...

The command’s declared return type is unchanged. Morloc code that composes scan still sees <IO> [Hit]; the actions exist only at the interface.

@parse is the same idea on the way in: it reads an argument from a file in another format before the command runs (see Reading files in other formats). A command can use both; its arguments are read first, whatever output action is chosen.

6.10.1. @with keeps a value; @render produces bytes

The two directives differ in what they do with the formatter’s result.

@render treats the result as the final bytes. They are written verbatim, without quoting or escaping, and -f no longer applies. That is how sift declares -p, and it is why the output is readable:

$ ./sift scan the notes -p
notes/todo.txt:2:fix the parser
notes/todo.txt:3:write the manual
notes/2026/plan.txt:1:fix the build
notes/2026/plan.txt:2:ship the manual

@with keeps the result typed. It flows through -f like any other return value, so the same Str comes out as a JSON string. Change the one word in sift.loc — 

--' @with   -p/--plain=asLines

 — rebuild, and the same command gives you this instead:

$ ./sift scan the notes -p
"notes\/todo.txt:2:fix the parser\nnotes\/todo.txt:3:write the manual\nnotes\/2026\/plan.txt:1:fix the build\nnotes\/2026\/plan.txt:2:ship the manual\n"

A @render handler must return Str or [U8]. Use @with when the result is data for something else to read, and @render when it is text or bytes for a human or a file.

A third case falls out of the same rule: a formatter that returns () is a sink. It has already done the writing itself — printed, saved a file, sent a request — and nothing goes on the wire. Unit in the Return: table marks one.

The table marks the framing too: (raw bytes) after a type means the row is a @render action, so those bytes go out as they are and -f does not apply to them.

6.10.2. Giving a formatter arguments

A formatter may take arguments besides the value it formats. $1, $2, …​ refer to the command’s own arguments by position, and @value refers to the value being formatted. Write them as a call:

rep.py
def query(q):
    return [[q + "-" + str(i), i] for i in range(3)]

def tabulate(width, rows):
    for name, n in rows:
        print("%-*s%d" % (width, name, n))

def as_json(rows):
    import json
    return json.dumps(rows)
rep.loc
module rep (report)

import root-py

source Py from "rep.py" ("query", "tabulate", "as_json" as asJson)

query :: Str -> <IO> [(Str, Int)]

--' Print the rows as a table, `width` columns wide
tabulate :: Int -> [(Str, Int)] -> <IO> ()

--' Render the rows as JSON text
asJson :: [(Str, Int)] -> Str

--' Run a query
--' @render -t/--table=tabulate($2) @default
--' @with   -j/--json=asJson
report ::
  --' the query to run
  Str ->
  --' output width
  Int ->
  <IO> [(Str, Int)]
report q _ = query q

The @default on -t is the subject of the next subsection; ignore it for the moment and pass the flag explicitly.

$2 passes report’s second argument — the width — into `tabulate:

$ ./rep fruit 12 -t
fruit-0     0
fruit-1     1
fruit-2     2

$ ./rep fruit 20 -t
fruit-0             0
fruit-1             1
fruit-2             2

The value being formatted is appended last unless you place it yourself, so tabulate($2) applies tabulate width rows, while tabulate(@value, $2) would apply tabulate rows width.

6.10.3. Choosing a default

Mark one action @default and it writes to stdout when no action flag claims stdout and no -f is given (see Several outputs in one run for actions given a path). That is how a command gets human-readable output by default while keeping its typed output one flag away:

--' Run a query
--' @render -t/--table=tabulate($2) @default
--' @with   -j/--json=asJson
report :: ...
$ ./rep fruit 12
fruit-0     0
fruit-1     1
fruit-2     2

$ ./rep fruit 12 -j
"[[\"fruit-0\", 0], [\"fruit-1\", 1], [\"fruit-2\", 2]]"

$ ./rep -f json @ fruit 12
[["fruit-0",0],["fruit-1",1],["fruit-2",2]]

An explicit -f suppresses the default, which is what makes the typed output reachable again. At most one action per command may be @default.

6.10.4. Media types

Bytes carry no label. A PNG and a CSV are both [U8] as far as the type system is concerned, and a caller that receives one has no way to tell which. @mime attaches a media type (RFC 6838 type/subtype) to a type, once:

ramp.py
import struct
import zlib


def make_png(n):
    def chunk(typ, data):
        c = typ + data
        return (struct.pack(">I", len(data)) + c
                + struct.pack(">I", zlib.crc32(c) & 0xffffffff))
    raw = b""
    for _ in range(n):
        raw += b"\x00" + bytes([(x * 255) // max(n - 1, 1) for x in range(n)])
    png = b"\x89PNG\r\n\x1a\n"
    png += chunk(b"IHDR", struct.pack(">IIBBBBB", n, n, 8, 0, 0, 0, 0))
    png += chunk(b"IDAT", zlib.compress(raw))
    png += chunk(b"IEND", b"")
    return list(png)


def ident(bs):
    return bs
ramp.loc
module ramp (ramp)

import root-py

--' A PNG image
--' @mime image/png
type PNG = [U8]

source Py from "ramp.py" ("make_png", "ident")

make_png :: Int -> PNG

--' Write the image bytes to standard output
ident :: PNG -> PNG

--' Draw an n-by-n grayscale ramp
--' @render -w/--write=ident
ramp :: Int -> PNG
ramp = make_png

Declaring it on the type rather than on each use means it cannot disagree between uses; a conflicting @mime along an alias chain is a compile error. The label replaces the type name wherever the type surfaces:

$ ./ramp -h
Draw an n-by-n grayscale ramp
...
Return:
  default:    image/png
  -w/--write: image/png    (raw bytes)
  A PNG image

It does more than label. The HTTP daemon returns the raw bytes with a matching Content-Type instead of a JSON envelope, and the MCP server delivers them as an inline image block rather than an array of numbers — see Serving. A media-typed return must reduce to Str or a byte array ([U8]), or a list of either; anything else is rejected at compile time.

Note

@mime does not yet change what the CLI writes. Without an action flag the bytes still come out as a JSON array of numbers, so a @render sink is currently how you get a file (reports/0029):

$ ./ramp 4 | head -c 60
[137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,4,0,0,0

$ ./ramp 4 -w > out.png
$ file out.png
out.png: PNG image data, 4 x 4, 8-bit grayscale, non-interlaced

The ident handler above exists for exactly this reason.

6.10.5. Several outputs in one run

An action flag takes an optional path, attached with =. --json writes to stdout as before; --json=rows.json writes to the file instead. Several actions may be named at once, each with its own path, and the command runs once to feed them all — the way to get a genome and its annotation table from one expensive run:

$ ./rep fruit 12 --json=rows.json
fruit-0     0
fruit-1     1
fruit-2     2

$ cat rows.json
"[[\"fruit-0\", 0], [\"fruit-1\", 1], [\"fruit-2\", 2]]"

An output goes to one place: a file or stdout, never both. Stdout gets a bare action (no =) if one is named, otherwise the @default action, unless -f is given or the @default action itself was given a path; then stdout gets the typed value. Above, stdout still carries the @default table. ./rep fruit 12 --table=table.txt writes the table to the file and the typed value to stdout. --no-stdout sends nothing to stdout:

$ ./rep fruit 12 --table=table.txt --json=rows.json --no-stdout

$ cat table.txt
fruit-0     0
fruit-1     1
fruit-2     2

tabulate is a sink — it prints its rows itself — and what it prints goes to its file. Whatever the command body prints goes to stdout, as it always does.

Each file holds exactly what the action writes when run alone, -f and -z included. On a streaming command every action sees the whole stream, batch by batch as the producer wrote it (see Streaming output with @collect). Every file is written under a temporary name beside it and moved into place only when the whole run succeeds; if the command or any action fails, or the run is interrupted, no requested file is created or changed. A file that is replaced keeps its permissions, and a path through a symlink writes the file the link names. An existing path that is not a regular file (/dev/null, a named pipe) is written directly.

Only = attaches a path. --json rows.json is still the flag followed by a positional argument, and a short flag (-j) always means stdout, as does --json=-.

What makes this possible is that the run saves the command’s output, and the actions read it back. Two things follow. An action that writes to stdout in such a run does so after the command finishes, not as it streams. And an action on a streaming command that can stream from more than one @collect in a run, or from one in a where-binding, or that has a branch streaming nothing, runs only on saved output, so the daemon and MCP servers, which run one action per call, do not offer it. A command whose branches (guard or match arms) each end in one @collect is not such a command: its actions run directly.

A -f that one of the outputs cannot take — csv for a value that is not a table, or mpk for a stream — is refused before the command runs.

6.10.6. Rules and rejections

  • At most one action writes to stdout. Two bare action flags, or a bare one with --no-stdout, are rejected, as are one path given to two actions, a path that is also -o’s, and an empty `--act=.

  • A streaming command with actions must return (): its actions apply to what it streams.

  • An action cannot refer with $N to a streamed @parse argument: the command consumes that stream as it reads it.

  • Action directives belong in the signature preamble — the --' lines directly above name :: — not on argument docstrings, record fields, or type aliases.

  • The command needs an explicit signature.

  • Flag names must not collide with the command’s own @arg / @true / @false names, with each other, or with -h / --help or --no-stdout.

  • Two directives whose long flags collapse to the same internal name (say --bar-baz and --bar_baz) are rejected, as is a synthesized entry name that collides with a top-level identifier in the module.