Workspace
Every issue Lorenz works gets its own directory on disk, where the coding agent clones the repo, installs dependencies, and edits files. This page is for operators who configure where that directory lives, what bootstrap commands run inside it, and how it is cleaned up. It covers the workspace path layout, the four lifecycle hooks, root resolution, containment rules, and the skills overlay.
What a workspace is
A workspace is a per-issue working directory. When Lorenz picks up an issue, it creates one
directory, runs the agent inside it, then removes it. The directory is the agent's current working
directory (cwd) for the whole run. Hooks populate it before the agent starts (clone, install,
configure) and tear it down afterward.
The @lorenz/workspace package owns this lifecycle. It creates the directory, runs the four hooks,
overlays skill directories, enforces that the agent never escapes the configured root, and deletes
the directory on cleanup. Workspaces can live on the machine running Lorenz or on a remote
worker reached over SSH.
Where a workspace lives
The path is <root>/<safe-identifier>[/<slot>].
<root>isworkspace.root(see Root resolution).<safe-identifier>is the tracker issue identifier with every character outside[A-Za-z0-9_.-]replaced by_. SoENG-1234staysENG-1234, butfeat/loginbecomesfeat_login.<slot>is a numeric slot index, appended only for ensembles (where one issue runs in parallel across several slots) or when co-residence forces it.
The slot suffix rules:
| Case | Path |
|---|---|
| Solo run (ensemble size 1) | <root>/<safe-identifier> |
| Ensemble (size > 1) | <root>/<safe-identifier>/<slotIndex> |
| Forced slot suffix | <root>/<safe-identifier>/<slotIndex> (applied unconditionally) |
The forced suffix exists so two slots of the same issue cannot share the bare path on one machine.
Two distinct identifiers that differ only in sanitized characters collapse to the same directory
name. feat/login and feat:login both become feat_login. Lorenz does not otherwise
disambiguate, so keep tracker identifiers distinct in their alphanumeric characters.
Lifecycle and hooks
Four hooks run at fixed points in the workspace lifecycle. Each is a shell command you set under the
hooks: block. All four default to null (no command).
| Hook | Config key | When it runs | On failure |
|---|---|---|---|
after_create |
hooks.after_create |
Once, right after the directory is created | Fail-fast: aborts workspace creation |
before_run |
hooks.before_run |
Once per attempt, before the agent session starts | Fail-fast: aborts the attempt |
after_run |
hooks.after_run |
Once per attempt, after the session ends | Best-effort: logged and ignored |
before_remove |
hooks.before_remove |
Once, before the directory is deleted | Best-effort: caught and ignored |
Two hooks are fail-fast, two are best-effort. after_create and before_run abort if they fail or
time out: a broken clone or a failed npm install stops the run before the agent wastes a turn on a
half-built tree. after_run and before_remove swallow failures by design. A failing teardown or
cleanup hook does not surface as a run failure; after_run emits a stderr update (Ignoring after_run hook failure ...) and the run proceeds.
before_run and after_run run once per attempt, not once per agent turn. before_run fires after
the workspace is prepared and before the agent session opens, so it wraps the entire turn loop.
after_run fires once the loop finishes, in a finally block, so it runs whether the attempt
succeeded or failed.
How hooks execute
Locally, each hook runs as bash -lc <command>, a login shell, with the workspace directory as
cwd. The subprocess is spawned detached in its own process group so a timeout can kill the whole
group (including any backgrounded children).
Over SSH, the hook runs as cd <workspace> && <command> on the worker host. Output is captured with
stdout and stderr merged.
Hook output surfaces as a hook_execution event carrying a HookExecutionMessage with status of
started, completed, or failed. Captured output is truncated to 4096 characters, with a
[truncated N chars] suffix and outputTruncated / errorTruncated flags when it exceeds that.
Timeout
hooks.timeout_ms bounds all four hooks. The default is 60000 (60 seconds).
When a hook exceeds the timeout, Lorenz sends SIGTERM to the process group, then SIGKILL after a
5000 ms grace, and the hook fails with hook timed out after <n>ms. For a fail-fast hook, that
aborts the run.
Issue templating
Hook commands can reference the issue through a Liquid template. Templating activates only when the
command references issue. or issue[ inside {{ or {% and an issue is present. Otherwise the
command passes through unchanged.
The template engine runs with strict variables and strict filters, so an unknown issue field throws
rather than rendering blank. Every interpolation is shell-escaped by default. Use | raw to opt out
of escaping and | shell_escape to escape explicitly without double-escaping.
The issue context exposes snake_case keys: id, identifier, title, description, priority,
state, state_type, branch_name, url, assignee_id, blocked_by (an array of
{id, identifier, state, state_type}), labels, assigned_to_worker, created_at, updated_at.
Bootstrap example
A typical after_create clones the repo and installs dependencies; a before_run checks out the
issue's branch. Set strict shell options so any failed step aborts the fail-fast hook:
hooks:
timeout_ms: 300000
after_create: |
set -euo pipefail
git clone git@github.com:acme/app.git .
npm ci
before_run: |
set -euo pipefail
git fetch origin
git checkout -B {{ issue.branch_name }} origin/main
set -euo pipefail makes the hook exit non-zero on the first failing command, an unset variable, or
a broken pipe, so a fail-fast hook stops the run instead of leaving a half-prepared tree. The default
60-second timeout rarely covers a fresh clone plus install, so raise hooks.timeout_ms to fit your
repo.
Root resolution
workspace.root is resolved in this order:
LORENZ_WORKSPACE_ROOTenvironment variable, if non-empty.workspace.rootfrom your config YAML.- Default:
<os.tmpdir()>/lorenz_workspaces.
The chosen value is expanded before use:
- A leading
~or~/expands against$HOME(or$USERPROFILE). - A whole-value
$VARexpands to that environment variable.
Expansion of $VAR is whole-value only. $HOME expands, but $HOME/work does not: an embedded
variable passes through unexpanded. Only the ~ / ~/ forms get path-prefix treatment. To build a
path under a variable, use the ~/... form or set the full path in LORENZ_WORKSPACE_ROOT.
The workspace block has no snake_case aliases. Write root and isolation directly. Only the
hooks keys have aliases (after_create, before_run, after_run, before_remove, timeout_ms).
Remote root resolution
For SSH workers, the unexpanded form of root is preserved separately as rootExpression. If that
expression is ~ or starts with ~/, Lorenz looks up $HOME on the worker over SSH and joins
against it, rather than reusing the locally expanded path. A ~/lorenz_workspaces root therefore
resolves to the worker's home directory, not the orchestrator's.
Containment and safety
The agent's cwd must stay inside the configured root. Lorenz realpaths the root and checks every
workspace path against it.
It rejects:
- A blank, newline-containing, or missing
cwd(invalid_workspace_cwd). - The workspace root itself used as a
cwd(refusing to use workspace root as cwd). - A path whose canonical form leaves the canonical root via a symlink (
unsafe symlink in workspace pathorworkspace outside root). - A symlinked root.
Containment relies on realpath, so a symlink pointing out of the root is caught even when the literal
path string looks contained. The check re-runs immediately before each local hook, to catch a cwd
swapped under a symlink after creation. On remote workers, a pure-shell canonicalize routine runs the
same check and rejects symlink escapes. See Security for the full threat model.
Skills overlay
Skills you configure under agent.skills are copied into each workspace at
.lorenz/skills/<basename>. The destination is a fixed string and is not configurable through
workspace settings.
Each sync (re)writes a .gitignore containing * at the skills root, so the agent never commits
overlaid skills. Each source must be a real directory whose entire subtree contains no symlinks. A
symlink anywhere in the tree throws workspace_skill_source_symlink; a source that is a file throws
workspace_skill_source_unsupported; a missing source throws workspace_skill_source_missing.
Sources are de-duplicated, and each sync replaces the target unless source and target already share a
realpath.
For remote workers, the source directory is archived with tar and piped over SSH into a guard
script that rejects symlinked parents and targets before extracting. Remote sync needs a valid
positive-integer worker.ssh_timeout_ms (default 60000); a missing or invalid value throws
invalid_ssh_timeout before any transfer.
Ensembles
When an issue carries an ensemble:<n> label, Lorenz runs it across n slots in parallel, each with
its own workspace at <root>/<safe-identifier>/<slotIndex>. Each slot is an independent directory
with its own hook runs, so a before_run that checks out a branch runs once per slot. See
Context ensembles for how slots are scored and merged.
Shared mode
Set workspace.isolation to none to run every issue in the root directory itself rather than a
per-issue subdirectory. The default is per-agent (isolated workspaces).
In shared mode:
createWorkspaceForIssuereturns the root for every issue.- Lifecycle hooks never run. Configuring any hook is a config error
(
workspace.isolation = "none" does not support hooks; remove <names>). - The root is never auto-removed.
- Skills are still overlaid into the shared root.
Shared mode and hooks are mutually exclusive.
See also
- Configuration reference - every
workspace.*andhooks.*key with defaults - Security - the containment model and symlink-escape rejection
- Skills - what skills are and how the
.lorenz/skillsoverlay is assembled - Context ensembles - per-slot workspaces for parallel attempts
- Workers - running workspaces and hooks on remote SSH hosts