Chloe

Docs

Machines

An agent in a machine of its own: a container or a small virtual machine with one agent, a shell to build and try what it writes, and the two Linux users that keep what it builds away from what it knows.


Machines

An agent that writes code to run your routines needs somewhere to run it: to clone a repository, install what it needs, try the build. On a shared computer that place is the whole computer, so chloe gives an agent a shell only in a machine of its own: a container, or a small virtual machine, with one agent in it. An agent says it runs in one with workspace.sandbox in its agent.ts. The machine's config says so with machine.own in settings, and the shell ability refuses to load anywhere else: on a shared computer the shell is the whole computer, and no boundary in chloe holds that.

The two users

Everything inside a machine is one of two Linux users, and that split is the whole boundary: what a command runs as cannot read what chloe holds, and the kernel holds it.

User Runs Owns and may change May read
runtime chloe itself, and the agent's jobs data/ (run history, keys, the agent's memory), .env, the routines everything it owns
shell every command the agent runs (shellRun) the folders it makes in workspace/ the routines, its own home

chloe runs the agent's commands as the shell user through sudo, with an environment built in the tool and nothing inherited from chloe: no key, no token, nothing else chloe holds reaches a command. The machine's image carries the one sudoers line that allows it.

The layout

/home/runtime/chloe.config.ts   the machine's config, written when a machine is made
/home/runtime/.env              its keys, read by that config
/home/runtime/chloe/            the runtime itself
/home/runtime/data/             run history, the account, the tokens, and memory/: the agent's memory
/home/runtime/workspace/        the agent's work, one folder each, made by shellRun
/home/runtime/workspace/routines/   the agent itself: agent.ts, purpose, skills, jobs

The machine's home is the project chloe runs in, so the agent is workspace/routines/agent.ts in the config. workspace/ is sticky, like /tmp: the shell user can make folders there, but cannot remove routines, which is the runtime user's and readable by the shell user and never writable by it. Clones and loose code are scratch: a routine never depends on a workspace folder surviving, and everything that matters is committed somewhere you own.

Running an agent in one

Say so in the agent's own agent.ts, and list it in chloe.config.ts like any other agent:

export default defineAgent({
  id: "builder",
  model: "anthropic/claude-sonnet-5",
  purpose: prompt("purpose.md"),
  abilities: { shell: true },
  workspace: {
    sandbox: { type: "podman", image: "./machine/Dockerfile" },
  },
});

Your copy of chloe does not run it. It makes the machine, keeps it as the definition says, and shows the agent on its page with the others:

  • builds the image from the Dockerfile, as chloe-<id>:<hash>, and again when the file or the base image changes;
  • makes the container chloe-<id>, with the volumes chloe-<id>-data (its memory, runs and keys) and chloe-<id>-work (its work), on a free port on loopback, from your own port plus 100;
  • lends it the agent's folder at workspace/routines, so an edit you make is live in the machine at once;
  • writes the machine's config and .env: your copy's settings, all but serve and machine, and of agents only this agent's own, each secret read from its .env under the name it has in yours; the names in machine.keys and in the sandbox's keys; and a key that makes your copy its owner, kept in your state folder as machines/<id>.key;
  • runs the sandbox's setup script inside it, and starts it.

It does all of that again, keeping the volumes, when anything the machine is made from changes, and only starts it when nothing has. Nothing about the machine is written by hand: no port, no key, no command.

In sandbox What it is
type "podman" or "docker". Required.
image A Dockerfile, as a path from the agent's folder, starting FROM chloe-machine; or an image's name. Default: machine.image.
lend Files and folders of this computer the machine sees at the same path, each "read" or "write".
env Plain values for the machine's environment, like where a lent file is.
keys Names in your .env it is also given, under the same name.
setup A script in the agent's folder, run in the machine as chloe's user each time the machine is made: a clone, a package install.

workspace.filesystem says where the work is kept: { type: "volume" }, the default, or { type: "local", path: "/srv/work" }, a folder of this computer.

The agent's folder is read only in the machine, unless it is a git repository of its own, when the machine may change it and a self change is a commit there, as it is here. A folder inside your project's repository is changed from your computer. Anything lent for writing, or a folder that is its own repository, makes the machine's runtime user you, on this computer (Podman's keep-id), so it can write there.

The base image

machine/Dockerfile builds it: Node, git, the runtime, the two users, the sudoers line and the start script. Your copy builds it as chloe-machine, from the package it runs, when a machine needs it and it is missing or was built from another runtime: another version, or, in a clone, another commit (an edit not yet committed does not count). Every agent's image is then built again on top of it, and each machine made over, so they run the runtime your copy does. The base image it replaced is removed once nothing is built on it. With machine.image set to an image of your own, nothing is built for you.

To build it by hand, from a clone:

git clone https://github.com/carlosmartinezt/chloejs
cd chloejs
podman build -t chloe-machine -f machine/Dockerfile .

or in a project that installed the package:

podman build -t chloe-machine -f node_modules/@chloejs/core/machine/Dockerfile node_modules/@chloejs/core

A clone runs chloe from its source; the package runs its build. Both are the same image, and every agent's own image is built again on top of it.

The shell

abilities: { shell: true } adds shellRun: one shell command, run in the workspace as the shell user, with a folder for the piece of work, made if missing and shared by every command that names it. What it returns is the command's exit code and both its outputs.

A shell command is refused for the rest of a reply once a tool that reads from outside the agent has answered in it, the way a change to the agent is: what it read could be asking for the command. The owner's next message starts clean, and the shell's own output does not count as from outside, so two commands in a row are fine.

The command reaches the internet only through the proxy below. A host not on the machine's list fails, and so does anything that tries to read the agent's memory, its keys, or the routines as writable: those belong to the runtime user.

The proxy

chloe runs a proxy on loopback, and the shell's environment points at it. It carries only the hosts in machine.hosts, and it holds no key and adds no credentials, so what the shell runs cannot push or upload anywhere you did not name. A leading *. stands for every host under it.

settings: { machine: { hosts: [
  "github.com", "api.github.com", "codeload.github.com",
  "objects.githubusercontent.com", "raw.githubusercontent.com",
  "registry.npmjs.org", "pypi.org", "files.pythonhosted.org",
  "deb.debian.org", "security.debian.org", "my-registry.example",
] } }

The default list is GitHub, npm's and PyPI's registries, and Debian's package servers. Saying hosts replaces the whole list, so an owner adding their own registry writes the default names and theirs. A change holds on the next command, with no restart.

The machine's start script also stops the shell user reaching the internet any other way, with firewall rules. Where it could not (a rootless container takes the rules and never applies them, and no kernel today offers another way), the machine says so in its log, and the proxy is the only way out that well-behaved tools take, not the only way there is. A machine whose log says the firewall did not hold should not hold anything you would not hand a stranger's program.

Adding a tool

The image carries Node, git and the runtime. An agent whose work needs more adds it in its own Dockerfile, one line at a time, named as image in its sandbox:

FROM chloe-machine
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg

Nothing an agent installs this way reaches the runtime user's files, whatever the tool does: the users, not the packages, are the boundary.

Making one from the page

The page's New agent (on All agents) makes an agent and says where it runs: This computer, a folder in agents/ listed in chloe.config.ts with no shell, or A container, the same folder with a workspace of { sandbox: { type: "podman" } } (or docker), the shell on, and the folder a git repository of its own, so the agent can change itself in its machine. No model is asked anything: the same form makes the same agent, and the agent changes itself into what you ask for once you talk to it. It answers once the machine lists the agent.

POST /api/agents does the same for a program, and GET /api/places says where an agent can be made here and why one cannot.

On your page

Everything about an agent in a machine (its chat, runs, memory, files and jobs) is asked of the machine, which keeps its runs and writes every memory file it serves to its own audit log, with your address, before serving it. The frame a memory file is shown in is served by your own copy, sandboxed as always. While its machine is being made, the page says so.

You reach it as yourself. A token of your copy's reaches it as a token: your copy refuses what no token may do and a token made for another agent, and the machine applies its own rules for tokens (the agent has to bind an api channel to be chatted to). Somebody invited to your copy is nobody in the machine. When the machine does not answer, the agent stays on the list saying so. The key your copy keeps reaches everything the machine's login does, and is for your own copy of chloe and nothing else.

A machine that has stopped is started again when it is asked for, and a minute or two before one of the agent's jobs is due, from the jobs the machine last said it has. Under systemd it is started in a scope of its own, so restarting your copy does not stop it.

Sleeping between uses

A machine your copy makes never stops by itself. serve.stopAfter, in the config of a copy that runs in a machine, is the idle minutes before it stops itself (no request over its port, no run going or waiting, no job due in the next five minutes), for a machine that costs money while it runs; your copy starts it again as above. A copy whose agent binds a channel other than api never stops, because that channel listens for messages.

What is true and what is not

A container shares its host's kernel, so code inside it is one escape from everything on the host. That is fine for your own agents on your own machine, and it is why a machine that runs a stranger's agent should be a virtual machine, where the whole kernel is theirs alone.

What holds either way, because the kernel holds it rather than chloe: a command cannot read data/, .env, the tokens or the memory, cannot change the routines, cannot see chloe's processes (where the image could remount /proc), and reaches nothing of any other folder on the host. What chloe holds by its own rules: the proxy's list, and the refusal of a shell command after reading from outside. Both are code, and both are on every machine, whatever it runs on.