7.4. Building API interfaces

In addition to being CLI tools, compiled Morloc programs can run as long-lived daemons, accepting function calls over HTTP, TCP, or Unix sockets. A serving front-end (the router mode) aggregates several programs behind one HTTP port, serving both a JSON API (for HTTP clients) and MCP (for AI assistants) with optional bearer-token auth.

The daemon, HTTP, TCP, and socket machinery is already part of the morloc-nexus runtime that every compiled program wraps. To get a dedicated daemon executable, build the program with --daemon-out:

$ morloc make --daemon-out combatd combat.loc

This writes a ./combatd launcher next to the ordinary ./combat CLI (you can produce both at once with morloc make -o combat --daemon-out combatd combat.loc). Running ./combatd starts the program as a long-lived daemon and accepts the listener options shown below.

Note ./combatd is a thin wrapper around the shared runtime — it is equivalent to morloc-nexus daemon ./combat. Either form works; the dedicated executable is just the more convenient one to hand out and script against.

7.4.1. HTTP protocol

To start combat as a daemon on HTTP port 8080:

$ ./combatd --http-port 8080 &
morloc-daemon: listening on http://0.0.0.0:8080
$ DAEMON_PID=$!

The trailing & creates the process in the background and $! captures its PID for later shutdown (see the Shutdown section below). This command launches all language pool processes (Python and R in this case) as child processes in separate process groups. A thread pool handles concurrent requests. If a pool crashes, the daemon detects it restarts it automatically.

We can check the daemon’s health:

$ curl -s localhost:8080/health
{"status":"ok","result":[true]}

The /health endpoint returns the liveness status of each pool.

The running daemons are discoverable:

$ curl -s localhost:8080/discover | jq .
{
  "status": "ok",
  "result": {
    "name": "combat",
    "morloc_version": "0.94.0",
    "commands": [
      {
        "name": "rollAdv",
        "type": "remote",
        "return": { "type": "Int", "schema": "j" },
        "args": [],
        "desc": "Roll a pair of d20 dice and keep the larger result"
      },
      {
        "name": "fighterDamage",
        "type": "remote",
        "return": { "type": "Int", "schema": "j" },
        "args": [
          { "kind": "pos", "type": "Int", "schema": "j" }
        ],
        "desc": "Damage calculation for a fighter"
      },
      {
        "name": "intro",
        "type": "remote",
        "return": { "type": "Str", "schema": "s" },
        "args": [
          { "kind": "pos", "type": "Str", "schema": "s" }
        ],
        "desc": "Introduce a new battle!"
      }
    ]
  }
}

The morloc_version string identifies the compiler that produced the program. Each command has one of two type tags: "remote" (dispatched to a language pool) or "pure" (evaluated by the nexus itself — e.g. a plain composition that never crosses a language boundary). The return object bundles the general type and its wire schema; each entry in args uses the same shape (plus a kind field, "pos" for positional or "opt" for optional).

Note The front-end’s GET /discover/<program> (see below) returns this same per-program shape; its top-level GET /discover is a flatter index across all served programs.

Functions can be called over the port:

$ curl -s -X POST localhost:8080/call/rollAdv -d '[]'
{"status":"ok","result":18}

$ curl -s -X POST localhost:8080/call/fighterDamage -d '[15]'
{"status":"ok","result":12}

Bad commands will return sensible errors:

$ curl -s -X POST localhost:8080/call/fireball -d '[]'
{"status":"error","error":"Unknown command: fireball"}

Beyond the pre-compiled commands, POST /eval and POST /typecheck take a JSON body {"expr": "…​"} and evaluate (or type-check) a single Morloc expression on the fly:

$ curl -s -X POST localhost:8080/eval -d '{"expr":"import root-py; 1 + 2"}'
{"status":"ok","result":3}

POST /eval runs the expression in the eval sandbox. Beyond the base eval rules — it may use let/where/do but may not declare types, typeclasses, instances, source foreign code, or import local-filesystem modules — served eval is always sandboxed by two gates the operator configures when starting the server:

  • Module allow-list. The expression’s top-level imports are limited to the modules passed in --eval-allowed-modules (comma-separated). The default is empty, so an out-of-the-box daemon runs only pure, module-free expressions (literals and pure intrinsics like @show/@hash); grant access by curating the list. Matching is on the resolved module, so import M as N is checked against M.

  • IO-intrinsic ban. The expression may not write an IO intrinsic (@open, @save, @write, @stdin, …​) directly. IO reached through a function exported by an allow-listed module is fine, so a server exposes exactly the IO surface it chooses — wrapped in named functions — and never a raw filesystem primitive.

$ ./progd --eval-allowed-modules root-py &
$ curl -s -X POST localhost:8080/eval -d '{"expr":"import root-py; @write \"x\" 1"}'
{"status":"error","error":"IO intrinsics may not be used directly ..."}
$ curl -s -X POST localhost:8080/eval -d '{"expr":"import shell-py (run); run \"id\""}'
{"status":"error","error":"module '\''shell-py'\'' is not in the eval allow-list"}

This is the intended interface for exposing a curated set of server-side functions to untrusted callers — they can only compose what the operator allow-lists; arbitrary code upload is not possible. There is no unsandboxed served mode: for trusted, unrestricted evaluation use the local morloc eval CLI, and use morloc make server-side to build programs that need local modules. POST /typecheck only reports the inferred type and never executes anything, so it is not sandboxed the same way.

Every response also carries an HTTP status code that reflects the class of outcome, so HTTP clients with built-in retry / branching logic (curl --fail, axios, fetch) work as expected without parsing the JSON envelope. The JSON body is still always present for clients that prefer it.

Code Meaning When

200

OK

Success. The body’s result field carries the return value.

204

No Content

The response to a CORS preflight OPTIONS request. The daemon never dispatches OPTIONS through any handler; it answers immediately with the standard Access-Control-Allow-* headers and an empty body.

400

Bad Request

The request was malformed: missing required field, unparseable args JSON, wrong number of arguments, a value that didn’t match its declared schema, or a string containing an embedded NUL byte the target language can’t represent.

404

Not Found

The path or named resource doesn’t exist: an unknown HTTP endpoint (GET /nope), an unknown command (POST /call/fireball), or a binding name that wasn’t registered (DELETE /bindings/missing).

408

Request Timeout

A POST /eval or POST /typecheck expression consumed more CPU than the --eval-timeout budget (default 30s) and was killed by the kernel via SIGXCPU. This guard only applies to those two endpoints, which fork morloc eval/typecheck as a subprocess. POST /call/ requests dispatch into a pre-compiled pool worker and are not bounded by --eval-timeout — long-running calls there are allowed.

500

Internal Server Error

A genuinely server-side failure: a pool socket error, a fork/pipe failure, the eval engine returning an unexpected error, or any other state that wasn’t the client’s fault.

503

Service Unavailable

The service is temporarily unable to handle the request but the caller should retry. The daemon emits 503 during the brief window where it is tearing down and respawning a crashed pool; the router emits 503 when forwarding a request to a daemon in that state, or when its cluster /health reports at least one program unhealthy. All 503 responses include Retry-After: 1. Clients with built-in retry middleware (curl --retry, axios-retry, hyper-retry) will back off and re-issue automatically.

The same status-code mapping applies whether you call a single daemon directly or hit the router; the router forwards classification through unchanged. Client errors (4xx) describe something the caller can fix; server errors (5xx) describe something the caller should retry or report. Unix-socket and TCP clients see the same classification via the JSON envelope’s status and error fields, though they don’t get the HTTP-level Retry-After hint on 503.

7.4.2. TCP protocol

HTTP adds overhead per request: headers, text parsing, and the full HTTP framing around each message. When your client is a program rather than a browser or curl, you can skip all of that. The TCP protocol uses a compact binary framing — just a 4-byte big-endian length prefix followed by the JSON payload. This makes it well suited for service-to-service communication, high-throughput automated pipelines, or any context where you control both ends of the connection and want minimal overhead.

Start a daemon on TCP port 9001:

$ ./combatd --port 9001 &
morloc-daemon: listening on tcp://127.0.0.1:9001

Unlike the HTTP protocol, you can’t use curl to talk to a TCP daemon. You need a client that speaks the length-prefixed binary framing. Here is a minimal Python client:

tcp_client.py — minimal TCP client
import socket, struct, json

def recvall(s, n):
    data = b''
    while len(data) < n:
        chunk = s.recv(n - len(data))
        if not chunk:
            raise RuntimeError("Connection closed")
        data += chunk
    return data

def call(host, port, method, command=None, args=None):
    msg = {"method": method}
    if command: msg["command"] = command
    if args is not None: msg["args"] = args

    payload = json.dumps(msg).encode()
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((host, port))
    # send 4-byte big-endian length, then the JSON payload
    s.sendall(struct.pack('>I', len(payload)) + payload)

    # read the 4-byte response length, then the response
    resp_len = struct.unpack('>I', recvall(s, 4))[0]
    resp = recvall(s, resp_len)
    s.close()
    return json.loads(resp)

print(call("localhost", 9001, "call", "rollAdv"))
# {"status": "ok", "result": 18}

print(call("localhost", 9001, "call", "fighterDamage", [15]))
# {"status": "ok", "result": 12}

print(call("localhost", 9001, "health"))
# {"status": "ok", "result": [true]}

print(call("localhost", 9001, "discover"))
# {"status": "ok", "result": {"name": "combat", "commands": [...]}}

The request is a JSON object with a method field ("call", "discover", or "health"), an optional command field naming the function, and an optional args array.

7.4.3. Unix socket protocol

For processes running on the same machine, Unix domain sockets are the fastest option. They bypass the entire network stack — no TCP handshake, no port allocation, no loopback routing. This is how Morloc pools communicate with the nexus internally.

To start a daemon on a Unix socket:

$ ./combatd --socket /tmp/combat.sock &
morloc-daemon: listening on unix:///tmp/combat.sock

The wire protocol is identical to TCP: a 4-byte big-endian length prefix followed by the JSON payload. The only difference is the socket type.

unix_client.py — minimal socket client
import socket, struct, json

def recvall(s, n):
    data = b''
    while len(data) < n:
        chunk = s.recv(n - len(data))
        if not chunk:
            raise RuntimeError("Connection closed")
        data += chunk
    return data

def call(sock_path, method, command=None, args=None):
    msg = {"method": method}
    if command: msg["command"] = command
    if args is not None: msg["args"] = args

    payload = json.dumps(msg).encode()
    s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    s.connect(sock_path)
    s.sendall(struct.pack('>I', len(payload)) + payload)

    resp_len = struct.unpack('>I', recvall(s, 4))[0]
    resp = recvall(s, resp_len)
    s.close()
    return json.loads(resp)

print(call("/tmp/combat.sock", "call", "rollAdv"))
# {"status": "ok", "result": 18}

print(call("/tmp/combat.sock", "call", "fighterDamage", [15]))
# {"status": "ok", "result": 12}

print(call("/tmp/combat.sock", "discover"))
# {"status": "ok", "result": {"name": "combat", "commands": [...]}}

7.4.4. Running all protocols at once

You don’t have to choose. One daemon can listen through all three protocols at the same time:

$ ./combatd \
      --http-port 8080 \
      --port 9001 \
      --socket /tmp/combat.sock
morloc-daemon: listening on unix:///tmp/combat.sock
morloc-daemon: listening on tcp://127.0.0.1:9001
morloc-daemon: listening on http://0.0.0.0:8080

All three protocols hit the same daemon process and share the same pool processes. A request arriving over HTTP, TCP, or the Unix socket is dispatched identically — only the framing differs.

Ephemeral ports

If you don’t care which port the daemon binds to — which is the common case for tests, CI jobs, or any orchestrator running many daemons in parallel — pass 0 and the OS picks a free one for you. The actual port appears in the stderr ready line, and can also be written to a file in a fixed JSON shape:

$ ./combatd --http-port 0 --port 0 --port-file ports.json &
morloc-daemon: listening on tcp://127.0.0.1:46217
morloc-daemon: listening on http://0.0.0.0:39381

$ cat ports.json
{"http":39381,"tcp":46217,"unix":null}

The file is written atomically (via rename) only after every listener is bound, so a stat-waiting client never sees a half-written file. Missing listeners are null, never absent — the schema is fixed.

7.4.5. From single daemons to a router

Everything above shows a single program running as a daemon. This is enough when you have one service, but Morloc programs are designed to be composed. You might have a tavern program that picks character classes and races, and a combat program that resolves attacks and damage. Each is its own compiled Morloc program with its own pools.

You could start each one as an independent daemon on its own port and have your client keep track of which port maps to which program. But that gets tedious. The router solves this: it presents a single HTTP endpoint — a serving front-end — that serves the programs you select behind one port, forwarding each call to that program’s own daemon. The front-end runs no user code itself, so a crashing call takes down only its program’s worker (restarted automatically), never the front-end. Router mode is the router subcommand of morloc-nexus; in practice you launch it through mim start (see mim (Morloc Installation Manager)), which adds the container, loopback/token handling, and the installexposestart lifecycle.

The front-end exposes both adapters on the one port: MCP at POST /mcp (for AI assistants) and a JSON API at POST /call/<program>/<command> (for HTTP clients), plus GET /discover and GET /health.

The following diagram illustrates how a client request flows through the router to a program daemon and its language pools:

                  Client
                    |
                    | HTTP: POST /call/tavern/randomClass -d '[]'
                    v
             +--------------+
             |    Router    |  morloc-nexus router --http-port 9090
             |  (HTTP:9090) |  Reads manifests from fdb/ at startup
             +--------------+
              /            \
    Unix socket            Unix socket
            /                \
  +-----------+         +-----------+
  |  tavern   |         |  combat   |
  |  daemon   |         |  daemon   |
  +-----------+         +-----------+
       |                 /        \
       v                v          v
    Python           Python        R
     pool             pool        pool

Each daemon is a child process of the router, started lazily on first request. The router and its daemons communicate over Unix sockets using the same length-prefixed JSON protocol described above.

7.4.6. Router mode

Setup

To make a program available to the router, install it with --install. This installs the program under the exe/ directory (identified by its module name), where the front-end finds each named program’s exe/<name>/<name>-build/manifest.json at startup.

$ morloc make --install -o tavern tavern.loc
Installed 'tavern' to ~/.local/share/morloc/bin/tavern

$ morloc make --install -o combat combat.loc
Installed 'combat' to ~/.local/share/morloc/bin/combat

$ ls ~/.local/share/morloc/exe/
combat  tavern
Starting the router

Name the programs to serve (there is no serve-everything scan); --program serves a program over both adapters, --mcp/--api restrict it to one:

$ morloc-nexus router --http-port 9090 --program combat --program tavern
morloc serve: MCP at http://0.0.0.0:9090/mcp (5 tools) | API at http://0.0.0.0:9090/call/<module>/<command> | discovery at http://0.0.0.0:9090/discover

If an auth token is configured (--auth-token, or MORLOC_MCP_TOKEN), every /mcp and /call request must carry Authorization: Bearer <token>; /health and CORS preflight (OPTIONS) stay open. A non-loopback bind with no token is refused unless --allow-no-auth is passed.

Discovery

GET /discover is the API index — the served modules, the /call URL shape, and a pointer to the MCP tools/list catalog. GET /discover/<program> returns one program’s commands and their positional argument order:

$ curl -s localhost:9090/discover | python3 -m json.tool
{
    "api": { "modules": [ {"module": "combat", "call": "/call/combat/<command>", "help": "/discover/combat"}, ... ],
             "call": "/call/<module>/<command>", "note": "POST positional args as a JSON array." },
    "mcp": { "endpoint": "/mcp", "tools": 5, "note": "Use tools/list for the MCP catalog." },
    "eval": { "enabled": false, "endpoints": ["/eval", "mcp tool 'eval'"] }
}

$ curl -s localhost:9090/discover/tavern | python3 -m json.tool
{ "program": {"name": "tavern", ...}, "commands": [...] }
Calling functions

Calls are routed by program name in the URL: /call/<program>/<command>.

$ curl -s -X POST localhost:9090/call/tavern/randomClass -d '[]'
{"status":"ok","result":"Rogue"}

$ curl -s -X POST localhost:9090/call/tavern/randomRace -d '[]'
{"status":"ok","result":"Elf"}

$ curl -s -X POST localhost:9090/call/combat/rollAdv -d '[]'
{"status":"ok","result":17}

$ curl -s -X POST localhost:9090/call/combat/fighterDamage -d '[15]'
{"status":"ok","result":12}

$ curl -s -X POST localhost:9090/call/combat/intro -d '["Goblin"]'
{"status":"ok","result":"A wild Goblin appears!"}

The first call to a program starts its daemon automatically. Subsequent calls reuse the running daemon with no startup cost. If a daemon crashes between calls, the front-end detects the failure and restarts it transparently. The same commands are available to MCP clients at POST /mcp as tools named <program><command> (e.g. combatrollAdv), called with named arguments.

Error handling

A program that is not served on the API adapter is 404:

$ curl -s -X POST localhost:9090/call/dungeon/explore -d '[]'
{"error":"module not exposed on the API"}
Independent daemons vs router-managed daemons

A daemon started manually (e.g., ./combatd --http-port 8080) is completely independent of the front-end. The front-end only knows about the programs you named (--program/--mcp/--api), whose manifests live under the exe/ directory, and it starts its own daemon instances as child processes. If you start a daemon on your own and also serve the same program through the front-end, you will have two separate daemon processes — each with its own pool processes and its own state.

7.4.7. Shutdown

Send SIGTERM (or SIGINT) to stop a daemon or router gracefully. The daemon sends SIGTERM to each pool process group, waits briefly for clean exit, then sends SIGKILL to any stragglers. Unix socket files are removed.

$ kill $DAEMON_PID
morloc-daemon: shutting down

$ kill $ROUTER_PID
morloc-router: shutting down

When a router shuts down, it terminates all the daemons it started. There is currently no way to stop an individual program’s daemon through the router API — the router manages their lifecycles internally. If you need to restart a specific program, restart the router.

7.4.8. Summary

Role Invocation Description

Daemon

./<daemon-exe> (built with morloc make --daemon-out)

Run one program as a persistent service

Front-end (router)

morloc-nexus router --program <name>…​

Serve the named programs behind one HTTP port (MCP + JSON API); usually launched by mim start

HTTP (daemon)

--http-port <n>

RESTful JSON API (curl-friendly); 0 = ephemeral. The daemon also serves TCP (--port), a Unix socket (--socket), and --port-file for ephemeral ports; the front-end is HTTP-only.

Auth (front-end)

--auth-token / MORLOC_MCP_TOKEN

Require a bearer token on /mcp and /call

exe

--fdb <path>

Override the installed-program directory (default: $MORLOC_HOME/exe)