SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Self-host Deer Workflow: agent graphs on a VPS

Deer Workflow puts agent orchestration in reviewable TypeScript. Install it on a VPS with Bun, pin the version, and run one graph headless under systemd.

What you are building

Deer Workflow is a code-first runtime for agent graphs: the control flow lives in a TypeScript file you can review, and a coding agent does only the parts that need judgement. This guide installs it on one Ubuntu VPS, runs one example graph headless under systemd, and writes the machine-readable event stream into a log file you can search when a run fails at three in the morning.

The pieces are small. Bun runs the CLI. One coding agent CLI, Codex or Claude Code, does the model work. One pinned npm package holds the runtime. One TypeScript file holds your graph. A systemd service and timer run it on a schedule. Most of the length here covers the parts that actually break: PATH inside a systemd unit, agent credentials in a session with no login shell, and pinning a dependency first published in July 2026.

Visual builder, code, or just prompting the agent

A self-hoster automating work with a model picks one of three shapes, and they fail in different ways.

A visual builder gives you a canvas, a node library, and a user interface a non-programmer can open. That is a real advantage, and the field is crowded enough that there is a whole survey of self-hosted n8n alternatives to pick from. The cost is that the logic ends up as a JSON document written by a UI. The diff of that document is noisy, so reviewing a change means opening the canvas rather than reading the patch.

Prompting an agent directly is the second shape. You describe the whole job in a paragraph and let the model decide the order, the retries, and when to stop. It works until the day it decides differently. There is no diff, because there is no artifact: the plan lived in the conversation, and the conversation is gone.

Orchestration in code is the third. The order of steps, the fan-out, the retries and the error handling are ordinary TypeScript in git. The model is called at the points where judgement is needed and nowhere else. The cost is that somebody has to write and maintain that code, and a colleague who does not write TypeScript cannot edit it.

What a graph runtime buys, and what it costs

  • Control flow you can review. The graph is a file. A change to the retry policy shows up in a pull request as three changed lines, not as a moved box.
  • Failure handling in version control. What happens when step four fails is written down, tested, and tagged with the rest of your infrastructure.
  • An agent you can swap. The runtime ships adapters for Codex, Claude Code and Pi. Changing which one runs a step is one import.
  • An execution you can watch. Phases and events come out of the runtime as structured data, so a headless run leaves a record you can query.

The general practice, designing the loop the model runs inside rather than polishing a single prompt, is loop engineering, and a graph runtime is one concrete way to do it. What it costs is setup: a runtime to install, an agent CLI to authenticate, no interface for non-programmers, and a young dependency to keep an eye on.

The project is new, so pin the version

Deer Workflow is MIT licensed and it is new. As of 19 August 2026 the repository has 47 commits on main. npm holds three published versions: 0.0.1 and 0.1.0 on 26 July 2026, then 0.2.0 on 27 July 2026. There is a git tag for each, and the changelog is where you find out what changed between them. Its Unreleased section already removes the deer-workflow agent command, so main and the newest published version no longer offer the same CLI.

That is not a reason to avoid the project. It is a reason to install one exact version and know which one you installed.

  • Install an exact version, never a range.
  • Record that version in the same repository as your graphs.
  • After any upgrade, run your own graph once by hand before the timer runs it again.

Install Bun and one agent runtime

Everything below runs as a normal user with sudo rights. Do not run it as root. The agent CLIs store credentials under the home directory of the user that signed in, and the systemd unit later has to run as that same user to find them.

sudo apt update
sudo apt install -y curl unzip jq git nodejs npm
curl -fsSL https://bun.com/install | bash

The Bun installer unpacks a zip archive, so unzip has to be present first. The installer appends its PATH lines to your shell profile, and your current shell has already read that file, so open a new shell or add these two lines to ~/.bashrc yourself and reload it.

export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$HOME/.npm-global/bin:$PATH"
bun --version

That prints a version number. bun: command not found means the PATH line is missing from the shell you are in, not that the install failed. Run ls ~/.bun/bin before reinstalling anything.

Now the agent runtime. Codex CLI is the default, and it installs from npm. Set a user-level npm prefix so the global install needs no root.

npm config set prefix "$HOME/.npm-global"
npm install -g @openai/codex
command -v codex
codex

command -v codex should print a path under $HOME/.npm-global/bin. Running codex on its own opens the CLI, where you sign in with your ChatGPT account. Do that once now, while you can see the screen.

Claude Code works as an alternative runtime and has its own installer.

curl -fsSL https://claude.ai/install.sh | bash
claude --version

A working install prints a version such as 2.1.211 (Claude Code). Run claude once to log in. This is the same class of process, with the same access to your files, as any other agent you host, so the account and hardening notes in running a coding agent on a VPS apply here without change.

Install Deer Workflow and pin the exact version

bun install --global @deerwork-ai/deer-workflow@0.2.0
command -v deer-workflow

command -v prints the absolute path, normally /home/<your user>/.bun/bin/deer-workflow. Copy it somewhere. The systemd unit cannot use the bare name.

Keep the version in the install command. Dropping @0.2.0 installs whatever is newest on the day you run it, which on a project with 47 commits can change the CLI under a timer that nobody is watching.

Put the graphs in a git repository

mkdir -p ~/workflows/logs
cd ~/workflows
git init

Codex checks whether it is running inside a git repository, which is why CodexAgentConfig carries a skipGitRepositoryCheck option for the cases where you cannot give it one. On your own VPS you can give it one, and you should: a graph is code, and the case for writing orchestration as code falls apart if the code is not under version control. Create the logs directory now, because systemd will not create it for you.

Write one graph

A workflow is an ordinary TypeScript module. It exports meta, an object holding a name, a description and the ordered phase list, and it exports the handler as default or as a named run export. Inside the handler you call helpers from the package. phase() marks which stage the run is in, log() writes a progress line, agent() sends one prompt to the coding agent, parallel() runs a list of tasks at the same time, and pipeline() pushes a list of items through several stages.

Save this as ~/workflows/log-triage.ts.

import { agent, log, parallel, phase } from "@deerwork-ai/deer-workflow";

export const meta = {
  name: "log-triage",
  description: "Groups recent service errors and writes one short report.",
  phases: [{ title: "Collect" }, { title: "Classify" }, { title: "Report" }],
  exampleArgs: { service: "nginx", hours: 24 },
};

export default async function workflow(args: { service: string; hours: number }) {
  if (!args?.service) throw new Error("input needs a service name");

  phase("Collect");
  log(`Reading ${args.hours}h of logs for ${args.service}`);
  const found = await agent<{ patterns: string[] }>(
    `Read the last ${args.hours} hours of journalctl -u ${args.service} and list the distinct error patterns.`,
    {
      sandbox: "read-only",
      schema: {
        type: "object",
        properties: { patterns: { type: "array", items: { type: "string" } } },
        required: ["patterns"],
        additionalProperties: false,
      },
    },
  );

  phase("Classify");
  log(`Classifying ${found.patterns.length} patterns`);
  const notes = await parallel(
    found.patterns.map((pattern) => () =>
      agent(`Explain this error and its most likely cause: ${pattern}`, { sandbox: "read-only" }),
    ),
  );

  phase("Report");
  return agent(`Write a short operations report from these notes: ${JSON.stringify(notes.filter(Boolean))}`);
}

Four details in that file carry weight.

  • schema on an agent() call asks for structured output, and the call returns the parsed object. found.patterns is a real array the rest of the graph can loop over. Without a schema, agent() returns a string and you are parsing prose.
  • sandbox decides what that step may touch. read-only blocks writes, workspace-write allows guarded writes, and danger-full-access removes the guard. It is set per call, so a graph can read widely and write in one place.
  • parallel() takes functions, not promises. map((pattern) => () => agent(...)) builds a list of thunks, so the runtime decides when each starts. Passing agent(...) directly would start every call the moment the list is built.
  • A failed task inside parallel() becomes null and the run continues, because partial completion is allowed by design. So notes.filter(Boolean) is not decoration: skip it and a failed branch puts the text null into the prompt of the next step.

The plain agent() helper uses the default runtime, Codex. To send one step to Claude Code instead, import the agent class and call it directly.

import { ClaudeAgent } from "@deerwork-ai/deer-workflow";

const claude = new ClaudeAgent({ sandbox: "read-only" });
const summary = await claude.run<string>("Summarise ./report.md in five lines.");

That is what a swappable agent looks like in practice: one import and one constructor, with the graph around it unchanged. The --agent codex|claude|pi flag on the CLI belongs to deer-workflow create, which generates a workflow file from a description. It does not change which runtime deer-workflow run uses.

Run it once by hand, then headless

cd ~/workflows
deer-workflow run ./log-triage.ts --input '{"service":"nginx","hours":24}'

Interactively you get a terminal interface: the phases from meta on one side, the live log on the other. Watch one complete run this way before you automate anything. If the agent is not logged in, or your input does not match the handler signature, you see it in seconds instead of finding it in a log file next week.

For automation, move the input into a file. Save ~/workflows/input.json:

{ "service": "nginx", "hours": 24 }
deer-workflow run ./log-triage.ts --input-file ./input.json --print >> logs/run.jsonl

--print, short form -p, turns the interface off and writes the event stream to stdout, one JSON object per line. Nothing else goes to stdout in this mode, so appending straight into a .jsonl file gives you a file where every line parses.

The event stream, and what to grep at 3am

Every line carries type, sequence, timestamp, workflowId, depth and scriptPath. The types are workflow:start, workflow:meta, workflow:end, workflow:error, workflow:phase:start, workflow:phase:end and log. Phase events carry phase, the end events carry durationMs, a log event carries message, and a workflow:error event carries error with name, message and usually stack.

That is enough structure to answer the two questions you have at three in the morning: did it finish, and where did it stop.

grep workflow:error logs/run.jsonl
jq -r 'select(.type == "workflow:error") | .error.message' logs/run.jsonl
jq -r 'select(.type == "workflow:phase:end") | [.phase, .durationMs] | @tsv' logs/run.jsonl
jq -r 'select(.type == "log") | .message' logs/run.jsonl

To watch a run that is happening now, follow the file: tail -f logs/run.jsonl | jq -c 'select(.type == "log")'. One run writes a small number of lines, but the file only ever grows, so add a logrotate rule for ~/workflows/logs/*.jsonl once the timer has been running for a few weeks.

Run it under systemd

Use a oneshot service plus a timer, rather than a long-lived daemon. The graph starts, runs, and exits. Write /etc/systemd/system/log-triage.service, replacing deploy with your user.

[Unit]
Description=Log triage workflow
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=deploy
WorkingDirectory=/home/deploy/workflows
Environment=HOME=/home/deploy
Environment=PATH=/home/deploy/.bun/bin:/home/deploy/.npm-global/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/home/deploy/.bun/bin/deer-workflow run ./log-triage.ts --input-file ./input.json --print
StandardOutput=append:/home/deploy/workflows/logs/run.jsonl
StandardError=journal
TimeoutStartSec=3600

Then /etc/systemd/system/log-triage.timer:

[Unit]
Description=Run the log triage workflow every night

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl start log-triage.service
systemctl status log-triage.service
sudo systemctl enable --now log-triage.timer
systemctl list-timers log-triage.timer

Start the service by hand first. A healthy run ends with the unit deactivating successfully, and logs/run.jsonl gains a block of events finishing with workflow:end. Only then enable the timer. list-timers prints the next scheduled run, and Persistent=true means a run missed while the server was off happens once at the next boot. StandardOutput=append: sends the event stream to the file and leaves the journal for everything else, so journalctl -u log-triage.service stays readable.

Why does the graph work in my shell but fail under systemd?

Check these four, in this order.

The unit cannot find the binaries. systemd never reads ~/.bashrc, and its default PATH contains neither ~/.bun/bin nor ~/.npm-global/bin. The unit fails in under a second and journalctl -u log-triage.service shows the exec failing on the command name. That is why ExecStart uses an absolute path, and why Environment=PATH= still lists both directories: the runtime itself has to find codex or claude when it starts an agent step.

The agent cannot find its credentials. The agent CLI reads its login from the home directory, so set User= and Environment=HOME= explicitly and give it the home you signed in with. A run that reaches workflow:start and then produces a workflow:error whose message comes from the agent CLI rather than from your own code is almost always this.

The run is killed after 90 seconds. For Type=oneshot, systemd applies its start timeout to the whole command, and the default is 90 seconds. An agent graph takes minutes. The journal records Start operation timed out. Terminating., the unit ends in a failed state, and the log file holds half a run with no workflow:end. TimeoutStartSec=3600 gives it an hour. Use infinity if you would rather it never be killed on time.

Relative paths resolve somewhere else. ./log-triage.ts and ./input.json are relative to WorkingDirectory. Leave that line out and systemd starts the process in /, where neither file exists.

What the orchestrator is allowed to do

An orchestrator that runs agent steps on a timer is a process acting on your server with nobody watching it. Two controls matter, and one budget.

The first control is the sandbox on each agent() call. read-only is the right default for any step that only reads: logs, metrics, a repository you are summarising. Move one step to workspace-write when it genuinely has to write, and keep the writable area small with additionalWritableDirectories instead of reaching for danger-full-access.

The second control is a person. Some steps should never run unattended: sending mail, moving money, deleting data, changing production configuration. In a code-first graph the gate is easy to place, because the step is a line of code. Stop the run, record the proposed action, wait for a human answer, then continue. Putting an approval gate in front of agent actions covers that pattern in full, and it belongs in any graph a timer starts.

The budget is money. Every agent() call is a full agent session, and parallel() starts several at once, so a graph that fans out to twelve branches runs twelve sessions every night whether or not anyone reads the report. The measurement and limits in keeping AI agent costs under control on a VPS apply directly to a scheduled graph.

Before you upgrade the runtime, read the changelog, install the new exact version, and run your graph once by hand with --print. On a project this young the CLI surface is still moving: the Unreleased section already drops a command that exists in 0.2.0. A graph under a timer is only as reliable as the version you pinned and the last run you actually watched.

FAQ

Do I need Bun, or will Node.js run Deer Workflow?

Install Bun. The published package points its deer-workflow binary at src/cli.ts, a TypeScript source file, and the documentation lists Bun as a prerequisite. Bun executes TypeScript directly, so there is no build step. Install it with sudo apt install -y unzip followed by curl -fsSL https://bun.com/install | bash, then confirm with bun --version. You still need Node.js and npm separately if you install Codex CLI from npm.

Why does my workflow run in the terminal but fail under systemd?

Almost always PATH, HOME, or the start timeout. systemd does not read your shell profile, so ExecStart needs the absolute path to deer-workflow and Environment=PATH= needs the directory holding codex or claude. The agent CLI reads its credentials from $HOME, so set User= and Environment=HOME= to the account you signed in with. And Type=oneshot inherits a 90 second start timeout, which kills an agent run part way through and leaves Start operation timed out. Terminating. in the journal, so set TimeoutStartSec=3600.

How do I use Claude Code instead of Codex for a step?

The plain agent() helper uses the default runtime, Codex. Import ClaudeAgent from the package, construct it, and call .run() for the steps you want Claude Code to handle. The --agent codex|claude|pi flag belongs to deer-workflow create, the command that generates a workflow file from a description, and it does not affect deer-workflow run. Whichever agent you use needs its own CLI installed and logged in as the same user the service runs as.

Which version of Deer Workflow should I install?

The exact one you tested. As of 19 August 2026 the newest published version is 0.2.0, from 27 July 2026, and the repository holds 47 commits. Write @0.2.0, or whatever is current when you read this, into the install command, keep that number in git next to your graphs, and run one graph by hand after every upgrade before the timer touches it again.