🐝 qory
A harness is what your coding agent reads before it works: instructions, skills, agents,
commands and settings. qory builds the harness of a repository from modules. Each module
is one piece of harness: the piece every repository of your team shares, the piece for
apps built on your framework, the piece this repository alone needs. The repository
commits the list of modules, not a copy of their files.
The problem
Your coding agent reads its harness from the repository. You have many repositories, and
more than one agent. Claude Code reads .claude, Codex reads .codex, Gemini CLI reads
.gemini. So every repository holds one copy per tool. When the shared part improves, the
copies drift, and nothing tells you which version a checkout runs with.
What qory does
A stack lists modules in order: the harness your team shares, the harness for apps built
on the framework, the harness this repository alone needs. qory composes them into one
tree and writes that tree the way each tool reads it. The tree is linked into the checkout
and kept out of git. A report says which module every entry came from.
A module is written once and serves Claude Code, Codex CLI, Gemini CLI, OpenCode, Cursor,
GitHub Copilot CLI, Amp, Goose, and every tool that reads AGENTS.md and .agents/skills.
A checkout can serve two tools at once, and both read the same harness.
The model is Docker's: a module is an image, the stack is the Compose file, and the
composed tree is what runs. One rule differs on purpose. When two modules provide the same
entry, qory refuses to compose until the stack says which one to keep. Nothing wins by
coming last.
Three steps
-
Write qory.yaml in the repository, or let qory setup repo write it:
apiVersion: qory.dev/v1alpha1
harness:
target:
runtime: claude # or both at once: [claude, codex]
model: opus
modules:
- name: core # what every repository of yours gets, pinned to a tag
source: {git: https://github.com/acme/harness, ref: v2.4.0, path: core}
- name: nextjs # the harness for Next.js apps
source: {path: ../harness/nextjs}
- name: app # this repository's own, committed with it
source: {path: ./harness}
Every module has a qory-module.yaml that names it. A repository can also take a
stack someone else delivers, a qory-stack.yaml: it names that stack under extends,
says what it composes it for under target, and adds its own modules. The delivered
modules cannot be changed. A runner that holds the stack tree names the base with
qory harness compose -f <stack> instead, and the repository's file then names no
version, ref or URL of it. The harness repository states the qory it needs once, in
its own qory.yaml, qory: ">=0.5.0", and every stack it delivers is held to it.
apiVersion: qory.dev/v1alpha1
harness:
extends: {git: https://github.com/acme/harness, ref: v2.4.0, stack: nextjs}
target: {runtime: claude, model: opus}
modules:
- name: marketing # the harness repository exports it too
source: {git: https://github.com/acme/harness, ref: v2.4.0, module: marketing}
- name: app
source: {path: ./harness}
stack and module name what the harness repository publishes in the exports
section of its own qory.yaml, so nobody outside it depends on its directories:
exports:
dir: ./harness # where stacks/ and modules/ are; default: the root
stacks: [nextjs]
modules: [core, nextjs, marketing]
-
Compose it:
qory harness compose
-
Start your agent in the checkout. It reads the composed tree through the files it
already looks for.
Install
With Homebrew:
brew install qoryai/tap/qory
With the install script, which puts the release binary in ~/.local/bin:
curl -fsSL https://raw.githubusercontent.com/qoryai/qory/main/install.sh | sh
From source, with Go 1.27 or later:
go install github.com/qoryai/qory@latest
To update, run qory update: it installs the newest release the way this qory was
installed, with brew, with go install, or by replacing the release binary in place
after checking it against the release's checksums. Every command looks for a newer release
when it runs on a terminal, asking GitHub at most once an hour, and says so after its
output when the newest release is ahead of its version. A build from main between
releases is told only when it is behind a release. Set QORY_NO_UPDATE_CHECK=1 to turn
that off; it is off when CI is set.
Try it
mkdir hello && cd hello
qory setup example # writes a stack and two modules
qory harness compose # or: qory hc; for Codex: qory hc --runtime codex
claude # type /hello
qory harness remove # or: qory hr
Both modules ship a greet skill. The README that qory setup example writes says which
line to delete to watch qory refuse the collision. For a real repository, run
qory setup repo instead.
The same example, observed, then behind a wall
qory run starts the agent on the composed harness inside the session runner: every
connection it makes goes through a proxy on your machine, and the session is recorded.
qory run -- -p "/hello" # one headless turn, observed
cat .qory/runs/*/events.jsonl # what it reached, what it said, how it ended
Without a wall the proxy sees only programs that honour it. A wall starts the agent
in a container whose one route out is that proxy. It needs the docker command and an
image of yours that holds the agent; qory builds none. A minimal one for Claude Code:
FROM node:22-slim
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/* && npm install -g @anthropic-ai/claude-code
# The container runs as your user, who has no home in the image.
ENV HOME=/tmp
docker build -t hello-agent:1 .
export CLAUDE_CODE_OAUTH_TOKEN=... # from `claude setup-token`; or ANTHROPIC_API_KEY
qory run --wall docker --image hello-agent:1 --env CLAUDE_CODE_OAUTH_TOKEN -- -p "/hello"
On a Mac, name the Linux build of the same qory release as wall.helper first; see
Running a session. That run hands the token to the container. The
last step keeps it outside: say in ~/.config/qory/runner.yaml what this machine has
and what the agent may reach,
apiVersion: qory.dev/v1alpha1
egress:
mode: enforce
allow: [api.anthropic.com]
credentials:
model:
env: CLAUDE_CODE_OAUTH_TOKEN
hosts: [api.anthropic.com]
auth: {scheme: bearer}
placeholders: [CLAUDE_CODE_OAUTH_TOKEN]
wall:
adapter: docker
image: hello-agent:1
and give the run a policy that selects the credential, kept outside the checkout:
# ~/hello-policy.yaml
version: 1
egress:
mode: enforce
allow: [api.anthropic.com]
credentials:
- name: model
qory run --policy ~/hello-policy.yaml -- -p "/hello"
The agent greets you as before. Inside the container CLAUDE_CODE_OAUTH_TOKEN is a
placeholder; the proxy sets the real token on each request to api.anthropic.com, which
the record lists with the credential's name and never its value, and everything else the
agent tries to reach is denied and recorded.
Worktrees
One branch per worktree, beside the main checkout. qory prepares the worktree the way
the repository's qory.yaml says and composes the harness into it:
# qory.yaml, committed, beside the harness section
apiVersion: qory.dev/v1alpha1
worktree:
base: main # the base branch, read on the remote; default: the remote's HEAD
link: [.env, .env.local] # linked from the main checkout into the worktree
run:
add: [pnpm install] # run in the new worktree
qory worktree add feature # ../wt-feature on branch feature, pushing to origin/feature
qory worktree add feature --base v1.2.0 # a branch that exists is moved onto the base, after a question
qory worktree add feature --offline # without the fetch every add starts with
qory worktree add --branch feature # attach to the remote's feature: fetched, tracked, refused when the remote lacks it
qory worktree add --pr 7 # attach to pull request 7: its branch, found among the remote's refs
qory worktree add review --pr 7 # the same, in ../wt-review
qory worktree remove # the worktree you stand in, and its branch
qory setup shell # make your shell cd into a new worktree, and back on remove
Every add fetches the remote first, so a new branch starts at the remote's tip and a
branch pushed from another machine is tracked instead of cut anew; a fetch that fails
stops the add, and --offline goes on with the refs already there. The branch goes with
the worktree on remove: quietly when every commit of it is on the remote, in the main
checkout or on the base it was cut from, and after a question otherwise, with push,
keep, delete anyway and stop as the answers. --keep-branch keeps it, and
worktree.branch: keep makes that the default. -v on either verb prints each git
command as it runs, and what the configured commands print.
--pr asks no hosting API: the pull request's head is one of the refs the remote
publishes, refs/pull/<n>/head on GitHub and Forgejo, refs/merge-requests/<n>/head on
GitLab, refs/pull-requests/<n>/from on Bitbucket Server. A host with another layout
names it as worktree.pr: refs/.../{n}/... in the repository's qory.yaml; one that
publishes none, Bitbucket Cloud, takes --branch with the pull request's branch.
Where a worktree goes and what it is called is your choice, not the repository's:
worktree.dir, worktree.name and worktree.branch in your own qory.yaml, which
qory setup machine writes. A file the repository must not name goes there too:
# ~/.config/qory/qory.yaml
worktree:
link: [{from: ~/secrets/app.env, to: .env}] # this machine's path, linked as .env
Commands
qory setup repo # write the repository's qory.yaml: its stack, module and worktree settings
qory setup example # write the hello example into the current directory
qory setup machine # write your qory.yaml in ~/.config/qory: how qory runs here
qory setup shell # completions, and a shell that follows worktree add and remove
qory harness compose # compose the stack into the checkout you stand in (qory hc)
qory harness inspect # the report: every entry and the module it came from (qory hi)
qory harness remove # remove the composed tree and its links (qory hr)
qory harness launch # the command that starts a runtime on the tree (qory hl)
qory run # start a runtime on the tree, observed and recorded
qory worktree add # add a worktree for a branch and prepare it (qory wa)
qory worktree remove # remove a worktree and its branch (qory wr)
qory worktree list # every worktree with its branch (qory wl)
qory config # every setting, its value and the file it came from
qory update # install the newest release; --check only says whether one exists
Flags worth knowing on compose:
-f <stack> compose this stack; as the base of the repository's document when that extends one
--dry-run print the report and write nothing
--runtime claude,codex render for these runtimes instead of the document's
--model opus write this model instead of the document's
--force replace a tracked, unmodified file where a link goes
--update fetch every git source again
--check exit 6 when the composed tree is behind the stack and modules
--home <dir> compose under a directory outside the checkout, and write nothing into it
--no-links keep the checkout untouched; the tree goes under .qory
Two qory.yaml files are read. Every setting has a default, so both are optional. The
repository's file is committed and holds what the repository decides: its stack, and what
a worktree needs; it may be named harness.yaml instead, for a file that says nothing of
the tool that reads it. Your file, in ~/.config/qory, holds how qory runs on this machine
for every repository: the runtime to compose for, where worktrees go, the git timeout.
The repository's file is read on top of yours. qory config shows every setting and the
file it came from. The reference, one page per command, is under
docs/commands.
What you get
- The files your tool reads, linked to a composed tree under
.qory and kept out of git.
Your own settings.local.json stays yours.
- Settings merged from every module, in the tool's own format. Permission lists and hooks
join. A value set twice to different things is a collision, never a silent override.
- MCP servers, one file each in a module, written where every tool reads them.
- Modules from a directory beside the repository, or from a git repository at a tag,
pinned by commit in the report. A repository that publishes stacks and modules lists
them under
exports, and a consumer names them, never their directories.
- One instruction file, joined from the modules in order, under the name each tool wants.
- A report that names the module of every entry. When two modules provide the same entry,
a refusal with the lines that resolve it.
- Part of a module, when that is all you want.
exclude leaves entries, the instruction
section, settings fragments or variables out. only takes the named things, plus what
they need as the module's manifest declares it, and nothing else: only: {skills: [deploy]} is the deploy skill, the command it runs and the agent it calls, from a
module full of other things. A required entry you take from another module instead is
one exclude line beside the only.
The composed tree
The home, .qory/harness, holds one directory per runtime. Most of what is in it is a
symlink to a file in a module: every skill, agent, command and hook. Beside the links
are the generated copies: AGENTS.md at the root of the home, the instruction file each
runtime wants, its settings and its MCP file, each joined from the modules' fragments.
Editing a module's file is live through the link. Editing a module's instruction section
or a settings fragment is not: the merged copy is generated, and it stays as it was until
the next compose. qory harness compose --check compares the home with what the stack
and modules say now and exits 6 when a merged copy is behind, which is the check a CI
job runs.
What the checkout holds is links too. .claude is a real directory with one symlink per
entry, so Claude Code's own settings.local.json stays beside them; .mcp.json and a
root AGENTS.md are symlinks. A tool that walks the tree has to follow them:
find needs -L.
grep -R given a symlink as its operand without a trailing slash reads nothing with
BSD grep on macOS and follows the link with GNU grep on Linux, and neither reports an
error. Give it the directory with the slash, grep -R pattern .claude/, or use -R -L.
rg needs --follow.
git status does not show the tree. Every path qory writes is listed in
.git/info/exclude, which every worktree of a repository shares.
A home outside the checkout
The tree can live outside the checkout, owned by whatever composes it, a workflow or a
launcher, and the checkout then carries nothing of it: no link, no .qory, no exclude
line, so a tracked harness/ directory is no collision and a pull is an ordinary pull.
--home <dir>, or harness.home in your own qory.yaml, names the directory, and
every checkout and worktree gets its own home under it. Each tool takes such a home
from its own command line or environment, and qory harness launch prints the command:
qory harness compose --home ~/.cache/qory/homes
eval "$(qory harness launch --runtime claude --home ~/.cache/qory/homes)"
For Claude Code that is --plugin-dir for a plugin the compose renders, --settings
for the permissions, hooks, environment and model, --mcp-config for the servers,
--append-system-prompt-file for the instructions, and --setting-sources user so no
.claude of the checkout or of a directory above it is read. Cursor takes the same
plugin, Codex takes the tree as its CODEX_HOME, OpenCode as its OPENCODE_CONFIG_DIR,
Copilot the skills and agents through --add-dir, Amp and Gemini their settings file.
Every line is the tool's own template, and harness.launch.<runtime> in your
qory.yaml changes the command, the arguments or the variables when a tool's flags
move. --json prints the same as one object. The contract says what each tool takes
from outside and what it still reads from the checkout.
Running a session
qory run starts the runtime on the composed tree inside the session runner of
qoryai/runner: the same launch as qory harness launch,
with every connection the runtime makes going through a proxy on your machine and
recorded, and the session written as events beside its output.
qory run # the composed runtime, at your terminal
qory run claude -- -p "Reply pong" # one headless turn; arguments after -- go to the runtime
The record is .qory/runs/<id>/: events.jsonl, one event per line, and output.log,
the session's bytes. One optional file, ~/.config/qory/runner.yaml, says what the
runner does on this machine. It lives beside your qory.yaml and nowhere else, so a
repository cannot set it:
# ~/.config/qory/runner.yaml
apiVersion: qory.dev/v1alpha1
egress: # what the runtime may reach; enforce denies the rest
mode: enforce # or observe: record everything, deny nothing
allow: [api.anthropic.com, "*.github.com"]
webhook: # where to post the events as well; optional
url: https://example.com/qory/events
secret: sixteen-characters-at-least # or QORY_WEBHOOK_SECRET in the environment
wall: # start the runtime in a container; optional
adapter: docker
image: example.com/agent:1 # yours: the runtime and your toolchain
env: [ANTHROPIC_API_KEY] # names; nothing else of your environment goes in
mounts: [/srv/odoo:ro] # what else of your machine it sees; optional
memory: 14g # and cpus, pids_limit, shm_size; optional
run: # optional
timeout: 5h30m # stop a runtime that still runs then
stop_signal: SIGINT # what asks it to leave; the runtime's descriptor's, else SIGTERM
stop_grace: 30s # between that signal and SIGKILL; 10s
qory run runs whichever runtime the harness is composed for, and none of the above is
particular to one. What the runner knows of a runtime is a descriptor, a file of data in
the runner contract's
format: how its hooks are installed, what its output means as events, which signal asks
it to leave. The runner ships Claude Code's. ~/.config/qory/runtimes/<runtime>.yaml
describes another, or replaces the one shipped; and a runtime nothing describes runs all
the same, the run, its log and its egress recorded and the session's own events not.
A module declares the hosts it reaches under egress in its manifest, and the compose
unions them into the report. When the harness declares, the runtime reaches the declared
hosts the policy covers and nothing else; the policy is the ceiling. When no module
declares, the policy's list stands as it is.
With a webhook configured the runner does not start unless the receiver answers;
--local runs with the files alone. A denied connection is recorded and
the session goes on; only a time limit you set ends a session: --timeout 5h30m, or
run.timeout, stops the runtime then, ai.qory.run.exited says the limit was the
reason, and qory run exits 124.
A system that starts runs of its own names them and brings each its policy:
qory run --headless --run-id "$uuid" --label run_key=1234 --label issue=77 \
--policy /etc/factory/shop-policy.yaml --timeout 5h30m --stop-grace 30s -- -p "$prompt"
--run-id is the id the caller already holds, a UUID in lower case, and the labels go
into ai.qory.run.started, where a receiver ties the run to what it knows. --policy
is one run's own policy, in the runner contract's format, kept outside the checkout. It
narrows only: under an egress section in mode enforce the run reaches the file's
hosts the section covers; with no section, or one in mode observe, the file stands as
it is. The webhook's secret stays the runner's: QORY_WEBHOOK_SECRET is taken out of
the session's environment.
Credentials the agent never holds
Behind a wall a run needs no credential inside the container. runner.yaml defines what
the machine has, and a run's policy selects among it by name:
# ~/.config/qory/runner.yaml
credentials:
model: # a token from qory run's environment
env: CLAUDE_CODE_OAUTH_TOKEN
hosts: [api.anthropic.com]
auth: {scheme: bearer} # or basic with a username, or header with a name
placeholders: [CLAUDE_CODE_OAUTH_TOKEN]
product: # a token from an adapter of yours
adapter: [/opt/adapters/code-host, --repo, "${argument}"]
argument: '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'
# the run's policy, given with --policy
version: 1
egress:
mode: enforce
allow: [api.anthropic.com, git.example.com, api.git.example.com]
credentials:
- name: model
- {name: product, argument: acme/shop}
The runner keeps each token outside the container, and its proxy sets it on the requests
to the hosts it is for. The container gets a placeholder where a program wants a
credential set, and never the token. An adapter is a program of yours that knows one
kind of host, a source code host say: it runs outside the container and prints the
token, its expiry, and the hosts, the scheme and the paths the token is for, so qory
names no host of its own. Its paths are the run's whole reach on those hosts: a
credential for acme/shop opens no other organization's repository, and a path the
adapter leaves out, the host's GraphQL endpoint say, is not reached. egress.paths in a
policy holds a host to paths the same way with no credential. The
runner's contract
has the adapter's document and the rules.
For those hosts, and no other, the proxy ends the container's TLS itself, with an
authority made for the run whose key never leaves the runner. The container is given
one bundle to trust, its image's own authorities and the run's certificate, through
SSL_CERT_FILE, GIT_SSL_CAINFO, NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE and
CURL_CA_BUNDLE, or the variables wall.ca_env names. The record lists the terminated
hosts and, for each request to one, the method, the path and the credential's name.
A job ends with qory run resend <run-id>, whatever happened before it. It sends the
receiver what it has not accepted of the run's record, and nothing twice. After a runner
that died it first closes the record, ai.qory.run.exited with reason: runner_lost,
and removes the containers and networks the run's wall left. It refuses a run that is
still going, and exits 1 when the receiver still has not taken everything after
--wait, two minutes unless named. The formats are in the runner's
contract.
The proxy sees only programs that honour it. A wall makes the rest fail: with a
wall section, or --wall docker --image <image> for one run, the runtime starts in a
container on a network with no route out, and reaches the proxy, and nothing else,
through a relay. The container sees the checkout and the composed home and nothing else
of your machine; the runner, the policy, the record and the webhook's secret stay
outside. It needs the docker command and an engine behind it, and an image of yours
that holds the runtime; qory builds none. What to know:
- The model credential goes in by name,
wall.env or --env, and is then the
agent's. A subscription login kept in a Mac's Keychain does not reach a container;
use an API key, or a token from claude setup-token.
- Inside the container the relay and the hook forwarder are qory's own Linux build. On
Linux that is the binary you run. On a Mac, download the Linux archive of the same
release for your engine's architecture and name the binary as
wall.helper.
- The container sees the checkout it was started in and no other directory, unless
--mount <path>[:ro] or wall.mounts shows it one, at its own path: a sibling
checkout the session reads, say. A socket is never mounted. In a git worktree the
repository's data lives in the main checkout, outside it, so git inside the container
works there only with that directory mounted; a clone works as it is.
--cpus, --memory, --pids-limit and --shm-size, or the keys of those names
under wall, limit what the container uses. A headless browser wants --shm-size 2g:
an engine's default /dev/shm is 64 MB.
- Behind a wall the proxy reaches your own machine only for a host
egress.allow
names itself, in either mode, and never the cloud metadata address. For a local model
endpoint or MCP server, list your machine's host name, and point the harness at that
name: localhost inside the container is the container.
- With the engine in a virtual machine, as on a Mac, the runtime's hooks do not reach
the runner, so a walled run there has no hook events; the log, the egress record and
the structured output are there. On a Linux host they cross.
The stack, the module manifest, qory.yaml, the composition rules and the runtimes are
specified in contracts/harness/v1, with JSON schemas and
the fixtures the test suite runs.
Contributing
See CONTRIBUTING.md. Contributions are made under the agreement in
CLA.md.
Licence
Apache License 2.0. See LICENSE. Qory™ is a trademark of 8wonders GmbH;
TRADEMARKS.md says what you may do with the name.