# 6.10. Output actions

Morloc Manual > Building CLIs | https://morloc-project.github.io/docs/clis/output-actions.html | prev: https://morloc-project.github.io/docs/clis/output-formats.md | next: https://morloc-project.github.io/docs/clis/streaming-output.md

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.

```morloc
--' 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:

```console
$ ./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
...
```

```console
$ ./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:

```console
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.

## 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:

```console
$ ./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` — 

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

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

```console
$ ./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**

```python
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**

```morloc
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``:

```console
$ ./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 fires when no action flag and no `-f` are given. That is how a command gets human-readable output by default while keeping its typed output one flag away:

```morloc
--' Run a query
--' @render -t/--table=tabulate($2) @default
--' @with   -j/--json=asJson
report :: ...
```

```console
$ ./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**

```python
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**

```morloc
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:

```console
$ ./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 [Building API interfaces](https://morloc-project.github.io/docs/apis/api-interfaces.md) and [Model Context Protocol (MCP)](https://morloc-project.github.io/docs/apis/mcp.md). 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`):
> 
> ```console
> $ ./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. Rules and rejections

-   One action flag per invocation. Siblings are mutually exclusive and a second one is rejected at parse time.
-   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`.
-   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.
