A compact Rust MCP toolbox for filesystem access, HTTP fetches, persistent memory, local process execution, reusable skills, and provider-backed local agents.
Documentation: see the full documentation site for a guided walkthrough, per-server references, configuration guides, and troubleshooting.
tuls packages six focused MCP servers into one binary:
| Server | Purpose | Primary capability |
|---|---|---|
filesystem |
Read, inspect, search, edit, and move workspace files | filesystem.read, filesystem.write |
fetch |
Fetch bounded HTTP(S) content with explicit network policy | network.fetch |
memory |
Maintain a persistent JSONL knowledge graph | memory.read, memory.write |
shell |
Execute programs with direct argv semantics | process.execute |
skills |
Discover and activate workspace skills | skills.read |
agents |
Run local provider-backed agents with child MCP tools | agents.run |
The project is designed around explicit capabilities, least privilege,
strict input schemas, and bounded I/O. It targets MCP 2026-07-28 and
uses that protocol lifecycle only.
- Documentation
- Why tuls
- Requirements
- Build and install
- Quick start
- Connecting from an MCP client
- CLI reference
- Capability policy
- Filesystem server
- Fetch server
- Memory server
- Shell server
- Skills server
- Agents server
- Agent configuration
- Provider configuration
- OpenRouter agents
- Child MCP servers
- Recommended agent profiles
- Workspace layout
- Naming conventions
- Security model
- Limits and bounded behavior
- Troubleshooting
- Development
The repository ships a full documentation site under
docs/, published at https://tuls-rs.github.io/tuls/. It includes:
- an overview and feature tour of all six servers;
- guided setup for MCP clients;
- per-server references with tool tables and JSON examples;
- agent, provider, OpenRouter, and child MCP configuration guides;
- the security model, limits, naming conventions, and a complete least-privilege example;
- troubleshooting and development guides.
Run it locally (from the repository root):
npm --prefix docs install
npm --prefix docs run docs:devtuls is intentionally small and opinionated. It does not try to make every
operation available to every model by default.
Core design rules:
- Tools are grouped by stable capabilities. A client can grant
filesystem.readwithout granting writes, ornetwork.fetchwithout shell execution. - A denied tool is not merely hidden. Disabled routes are removed from discovery and rejected again at call time.
- Agents are default-deny for child MCP tools. Declaring a child MCP server does not automatically grant its tools to the model.
- Unknown public parameters fail closed. MCP tool inputs reject unknown JSON fields, and agent definitions reject unknown fields.
- Secrets stay outside agent files. Provider credentials are read from environment variables; literal provider secret fields are rejected.
- Network and file operations are bounded. Large bodies, large media, tool results, process output, and provider responses have explicit limits.
- MCP annotations are descriptive, not authorization. Child tool annotations do not determine whether an agent may call a tool.
- The shell server is not presented as a sandbox. OS-level containment is a separate deployment responsibility.
| Requirement | Value |
|---|---|
| Rust edition | 2024 |
| Minimum Rust version | 1.98 |
| MCP protocol | 2026-07-28 |
| Primary transport for built-in servers | stdio |
| Child MCP transports | stdio, Streamable HTTP |
tuls supports only the MCP lifecycle implemented for 2026-07-28 and rejects
initialize requests. The MCP client used with this binary therefore needs to
support the same protocol lifecycle.
The crate is not yet published to crates.io. Once the first release is published, install it with:
cargo install tulsThe tuls binary is then available on your PATH. Check the CLI:
tuls --helpExpected top-level commands:
filesystem
fetch
memory
shell
skills
agents
Every GitHub release also attaches prebuilt binaries for Linux, macOS, Windows, iOS, and Android targets. Install the latest one for your platform with:
sh -c "$(curl -fsSL https://raw.githubusercontent.com/tuls-rs/tuls/main/install.sh)"The install.sh installer is also bundled inside every release archive. Set
TULS_RUST_LABEL=rust-stable to prefer binaries built with the latest stable
toolchain instead of the MSRV default (rust-msrv).
Until the crate is published, a source checkout builds and runs now; see Development.
tuls filesystem /absolute/path/to/project \
--allow filesystem.readtuls filesystem /absolute/path/to/project \
--allow filesystem.read \
--allow filesystem.writetuls fetch \
--allow network.fetchThe fetch defaults are intentionally restrictive:
--robots ignore
--network public
tuls memory \
--memory-file /absolute/path/to/memory.jsonl \
--allow memory.read \
--allow memory.writetuls shell /absolute/path/to/project \
--allow process.executetuls skills /absolute/path/to/project \
--allow skills.readtuls agents /absolute/path/to/project \
--allow agents.runtuls uses stdio for its built-in servers. MCP clients commonly represent a
stdio server as a command plus an argument array. The exact configuration file
and field names are client-specific, but the process configuration is
conceptually equivalent to the following:
{
"mcpServers": {
"project-files": {
"command": "tuls",
"args": [
"filesystem",
"/absolute/path/to/project",
"--allow",
"filesystem.read"
]
},
"web": {
"command": "tuls",
"args": [
"fetch",
"--allow",
"network.fetch"
]
},
"agents": {
"command": "tuls",
"args": [
"agents",
"/absolute/path/to/project",
"--allow",
"agents.run"
]
}
}
}The examples use tuls resolved through PATH. GUI applications may start
with a different working directory and a different PATH than your
interactive shell; if a client cannot resolve tuls, configure it with the
resolved absolute path to the installed binary.
There are two separate permission boundaries:
AI client / parent model
|
| connects to
v
tuls agents <workspace>
|
| spawn_agent("reviewer", ...)
v
provider-backed agent
|
| may connect only to configured child MCP servers
v
filesystem / fetch / other MCP servers
The parent model needs agents.run to use spawn_agent and send_input.
The spawned agent gets only the child MCP tools granted by its own
tools/disallowed_tools configuration. These are independent policies.
tuls <COMMAND> [OPTIONS]
| Command | Positional input | Important options |
|---|---|---|
filesystem [DIR]... |
Zero or more allowed directories; default . |
--allow, --deny |
fetch |
None | --robots, --network, --user-agent, --proxy-url, --allow, --deny |
memory |
None | --memory-file, --allow, --deny |
shell [DIR]... |
Zero or more allowed directories; default . |
--allow, --deny |
skills [DIR] |
Workspace root; default . |
--allow, --deny |
agents [DIR] |
Workspace root; default . |
--allow, --deny |
| Option | Repeatable | Meaning |
|---|---|---|
--allow SELECTOR |
Yes | Enables a capability or exact built-in tool. Once present, policy becomes an allowlist. |
--deny SELECTOR |
Yes | Denies a capability or exact built-in tool. Deny always wins. |
| Option | Values | Default | Meaning |
|---|---|---|---|
--robots |
respect, ignore |
ignore |
robots.txt policy for autonomous tool calls |
--network |
public, unrestricted |
public |
Outbound destination policy |
--user-agent |
string | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 |
HTTP User-Agent |
--proxy-url |
HTTP(S) URL | none | Outbound proxy; requires --network unrestricted |
| Option | Default | Meaning |
|---|---|---|
--memory-file PATH |
MEMORY_FILE_PATH, otherwise memory.jsonl |
Persistent JSONL knowledge graph path |
For filesystem and shell, relative paths resolve against the first
configured directory. If multiple roots are supplied, the first root remains
the default base rather than being reordered internally.
| Capability | Server | Scope |
|---|---|---|
filesystem.read |
filesystem | Read/list/search/inspect files |
filesystem.write |
filesystem | Create/edit/write/move files and directories |
network.fetch |
fetch | Fetch tool and fetch prompt |
memory.read |
memory | Read/search/open graph data and memory resource |
memory.write |
memory | Mutate graph data |
process.execute |
shell | Execute local programs |
skills.read |
skills | Activate discovered skills |
agents.run |
agents | Spawn agent tasks and send follow-up input |
Exact selectors use:
server/tool_name
Examples:
filesystem/read_text_file
filesystem/write_file
memory/search_nodes
agents/spawn_agent
With no --allow, a directly launched server exposes all of its own tools:
tuls filesystem .As soon as at least one --allow is present, the policy becomes an allowlist:
tuls filesystem . \
--allow filesystem.readGrant two capabilities:
tuls filesystem . \
--allow filesystem.read \
--allow filesystem.writeGrant read/write but remove one exact operation:
tuls filesystem . \
--allow filesystem.read \
--allow filesystem.write \
--deny filesystem/move_fileGrant a single exact tool:
tuls filesystem . \
--allow filesystem/read_text_fileInvalid capabilities, misspelled tool IDs, and capabilities that do not belong to the selected server are rejected at startup.
Resource/prompt note: An auxiliary MCP surface (the
fetchprompt, thememory://knowledge-graphresource) is enabled only when the controlling capability is itself granted and the controlling tool is not explicitly denied. Grantmemory.readfor the memory resource andnetwork.fetchfor the fetch prompt. An exact single-tool grant (for example--allow fetch/fetch) does not grant the capability, so the surface stays disabled; conversely--deny fetch/fetchor--deny memory/read_graphdisables the surface even when the capability is granted.
Start the server:
tuls filesystem <DIR>... [--allow SELECTOR]... [--deny SELECTOR]...| Tool | Capability | Purpose |
|---|---|---|
read_text_file |
filesystem.read |
Read bounded UTF-8 text; supports head or tail |
read_media_file |
filesystem.read |
Read a bounded media file into typed MCP content |
read_multiple_files |
filesystem.read |
Read a bounded batch of files |
list_directory |
filesystem.read |
List directory entries |
list_directory_with_sizes |
filesystem.read |
List entries with sizes and sort by name/size |
directory_tree |
filesystem.read |
Build a bounded tree representation |
search_files |
filesystem.read |
Search by glob-style pattern |
get_file_info |
filesystem.read |
Return filesystem metadata |
list_allowed_directories |
filesystem.read |
Show configured roots |
write_file |
filesystem.write |
Atomically replace file contents |
edit_file |
filesystem.write |
Apply structured edits or preview a diff |
create_directory |
filesystem.write |
Create directories |
move_file |
filesystem.write |
Move a file or directory inside allowed scope |
Public tool JSON uses camelCase:
{
"path": "src/main.rs",
"head": 120
}{
"path": ".",
"sortBy": "size"
}{
"path": ".",
"pattern": "**/*.rs",
"excludePatterns": ["target/**"]
}Unknown fields are rejected rather than ignored.
- All paths must remain under at least one allowed root.
- Relative paths resolve against the first configured root.
- Existing paths are canonicalized before authorization.
- Symlink entries are listed as symlinks; size listing does not follow a symlink just to obtain target metadata.
- Writes use a same-directory temporary file and rename for atomic replacement.
edit_filematchesoldTextexactly, with no whitespace or line-ending normalization (\nnever matches\r\n), and eacholdTextmust occur exactly once in the file; a missing or ambiguous match fails the call without writing.
Example review-only scope:
tuls filesystem /work/project \
--allow filesystem.readExample implementation scope without moves:
tuls filesystem /work/project \
--allow filesystem.read \
--allow filesystem.write \
--deny filesystem/move_fileStart the server:
tuls fetch [OPTIONS]| Tool | Capability | Input |
|---|---|---|
fetch |
network.fetch |
url, maxLength, startIndex, raw |
Example tool arguments:
{
"url": "https://example.com/docs",
"maxLength": 20000,
"startIndex": 0,
"raw": false
}raw: false converts HTML to Markdown where applicable. raw: true returns
page content without that simplification.
maxLength accepts 1–50,000 characters (default 5,000) and is a character
window for the rendered result, not a network safety limit; the raw response
body is bounded independently (8 MiB).
robots: ignore
network: public
redirects: disabled
DNS resolution timeout: 10 seconds
request timeout: 30 seconds
raw response body limit: 8 MiB
--network public permits only globally routable public destinations and
blocks special-purpose destinations such as:
- loopback addresses;
- RFC1918/private IPv4;
- IPv6 unique-local and link-local ranges;
- IPv6 site-local, translation, transition, and discard-only ranges;
- multicast and unspecified addresses;
- documentation/test ranges;
- local hostnames such as
localhostand.localnames; - IPv4-mapped IPv6 representations of blocked IPv4 addresses.
For hostnames, addresses are resolved and validated before the request and the validated addresses are pinned to the request client. Redirects are disabled, so a request cannot authorize one destination and then automatically follow a redirect to another.
Use unrestricted mode only when a separate network boundary already provides the required containment:
tuls fetch \
--network unrestricted \
--allow network.fetchA proxy is accepted only in unrestricted mode:
tuls fetch \
--network unrestricted \
--proxy-url https://proxy.example \
--allow network.fetchThis restriction exists because proxy-side DNS/routing cannot be constrained by the local public-destination check.
Autonomous fetch tool calls obey the configured --robots policy. The MCP
fetch prompt represents an explicit user-initiated fetch and does not apply
robots.txt, while still using the configured network policy and response
limits.
Start the server:
tuls memory \
--memory-file /work/state/memory.jsonl \
--allow memory.read \
--allow memory.writeIf --memory-file is omitted:
MEMORY_FILE_PATHis used when present;- otherwise
memory.jsonlis used in the process working directory.
| Tool | Capability | Purpose |
|---|---|---|
read_graph |
memory.read |
Read the graph |
search_nodes |
memory.read |
Search names, types, and observations |
open_nodes |
memory.read |
Open named entities |
create_entities |
memory.write |
Add entities |
create_relations |
memory.write |
Add relations |
add_observations |
memory.write |
Append observations to entities |
delete_entities |
memory.write |
Delete entities |
delete_observations |
memory.write |
Delete selected observations |
delete_relations |
memory.write |
Delete relations |
The server also exposes the knowledge graph as:
memory://knowledge-graph
Grant memory.read when resource access is required.
The graph is stored as JSONL and mutations rewrite a complete validated graph atomically. Existing Unix file permissions are preserved when replacing an existing regular file.
No-op mutations — duplicate entity/relation/observation creates, or deletes that match nothing — change nothing: they do not rewrite the file and do not send resource-update notifications.
Read-only memory:
tuls memory \
--memory-file /work/state/memory.jsonl \
--allow memory.readStart the server:
tuls shell <DIR>... \
--allow process.execute| Tool | Capability | Purpose |
|---|---|---|
execute_command |
process.execute |
Execute one program directly with an argv array |
Input example:
{
"program": "cargo",
"args": ["test", "--workspace"],
"cwd": ".",
"timeoutMs": 120000
}Important semantics:
programis an executable, not a shell command string;argsare passed exactly as individual argv values;- there is no shell parsing, quoting, glob expansion, pipelines,
&&, or variable expansion performed bytuls; - default timeout is 120 seconds;
- maximum timeout is 600 seconds;
- stdout and stderr are captured independently and bounded to 8 KiB each;
- child processes receive a minimal inherited environment;
- on Unix, timeout termination kills the whole process group, so descendants are terminated too; on Windows only the direct child is terminated, and a descendant process tree is not guaranteed to be killed.
If shell syntax is intentionally required, invoke the shell explicitly and accept the corresponding risk, for example:
{
"program": "bash",
"args": ["-lc", "cargo test && cargo clippy"],
"cwd": "."
}execute_command requires the MCP Tasks extension: the client must declare
the io.modelcontextprotocol/tasks client capability. The call returns a
standard task handle immediately; poll tasks/get for status and tasks/cancel
to terminate the process early. The completed task result carries a structured
CommandOutput (exitCode, stdout, stderr, timedOut,
stdoutTruncated, stderrTruncated).
Allowed directories constrain the command's working directory. They do not constrain the program's syscalls.
A spawned process can still access any path, network destination, device, or
other OS resource available to the account running tuls. For untrusted
models, run the shell MCP process inside an OS/container sandbox with explicit
filesystem and network policy. See SECURITY.md.
Skills are discovered from the workspace:
.agents/skills/<skill-name>/SKILL.md
.claude/skills/<skill-name>/SKILL.md
The canonical project location is .agents/skills. The .claude/skills
location is supported as a vendor adapter; canonical definitions take
precedence on collisions.
Start the server:
tuls skills /work/project \
--allow skills.read| Tool | Capability | Purpose |
|---|---|---|
activate_skill |
skills.read |
Load one discovered skill's full instructions and resource manifest |
A skill's supporting files are not automatically injected into the model context. The activation result provides resource paths so the model can read only what is needed.
Example layout:
.agents/
└── skills/
└── rust-review/
├── SKILL.md
├── checklist.md
└── examples/
└── review.md
The agents server lets a parent MCP client spawn named workspace agents.
Each agent has:
- model/provider configuration;
- instructions;
- optional skills;
- an explicit child MCP tool allowlist;
- optional child MCP stdio/HTTP servers;
- a bounded maximum number of provider turns.
Start it with:
tuls agents /work/project \
--allow agents.runBoth tools require the MCP Tasks extension: the client must declare the
io.modelcontextprotocol/tasks client capability, otherwise the server rejects
the call. Each call returns immediately with a standard task handle
(resultType: "task").
| Tool | Capability | Purpose |
|---|---|---|
spawn_agent |
agents.run |
Start a named agent on a task as an MCP Task |
send_input |
agents.run |
Continue an existing agent conversation as a new MCP Task |
1. spawn_agent(name="reviewer", task="Review src/fetch for SSRF issues")
2. poll tasks/get(taskId) until the task settles
3. read the terminal task result: agentId, agent name, final response
4. optionally send_input(target=<agentId>, ...) for follow-up turns
5. cancel an in-flight turn with tasks/cancel
The taskId returned by spawn_agent is distinct from the agent session
agentId; the session agentId appears in the structured content of the
terminal task result and is the target for send_input. While a task runs,
tasks/get reports statusMessage updates (starting child MCP servers, model
turn, tool execution). A completed turn settles with a CallToolResult:
successful runs carry {agentId, name, result} in structuredContent, failed
runs carry {agentId, name, kind, message, resumable} with isError: true.
The runtime supports multiple concurrent agents up to its configured runtime capacity.
Terminal sessions stay resumable: send_input on a completed agent starts a
new run that continues the retained conversation. Failed sessions are resumable
only for resumable error kinds (interrupts, transient provider errors, and
child MCP startup failures). Context limits, invalid provider requests, missing
provider credentials, and ambiguous tool execution mark the session
non-resumable. A session is resumable only after its last turn settles;
starting a replacement turn while one is still running is rejected.
Agent definitions are Markdown files with YAML frontmatter, discovered recursively under:
.agents/agents/
Discovery recurses through nested subdirectories, so agents can be organized into folders, for example:
.agents/agents/reviewer.md
.agents/agents/security/reviewer.md
.agents/agents/reviewer/agent.md
Name agent files in kebab-case (for example code-reviewer.md) so agent
names and filenames stay easy to compare at a glance.
Agent definitions under .agents/agents/ are executable trusted
configuration: they name the provider endpoint and credential variable, and
stdio child MCP entries declare commands that tuls executes locally under its
own OS identity. Running tuls agents against a workspace therefore executes
configuration shipped in that repository.
A custom provider endpoint receives the credential named by that definition's
credential_env. ${NAME} interpolation in child MCP environment values and
headers exposes the specifically named parent-process environment value.
Point the agents server only at workspaces you trust. An untrusted repository can define agents that make credentialed provider calls and run arbitrary local commands, so treat an untrusted repository the same as untrusted code.
Agents are Markdown files with YAML frontmatter, placed under
.agents/agents/. The frontmatter defines the agent; the Markdown body is the
instruction text. Instructions live in the body only — there is no
instructions frontmatter field. Discovery is recursive, and kebab-case
filenames are recommended.
Create:
.agents/agents/code-reviewer.md
---
name: code-reviewer
description: Reviews workspace code without modifying files
provider: openai
model: YOUR_OPENAI_MODEL
tools:
- filesystem/*
mcp_servers:
filesystem:
type: stdio
command: tuls
args: ["filesystem", ".", "--allow", "filesystem.read"]
---
Review the requested code and report concrete correctness, security, and
maintainability issues.Before starting tuls agents, make sure the provider credential exists in its
environment:
export OPENAI_API_KEY='...'
tuls agents . --allow agents.run---
name: code-reviewer-anthropic
description: Reviews workspace code using Anthropic
provider: anthropic
model: YOUR_ANTHROPIC_MODEL
tools:
- filesystem/*
mcp_servers:
filesystem:
type: stdio
command: tuls
args: ["filesystem", ".", "--allow", "filesystem.read"]
---
Review the requested code. Do not modify files.Credential:
export ANTHROPIC_API_KEY='...'| Field | Required | Default | Description |
|---|---|---|---|
name |
Yes | — | Stable local agent identifier, 1–64 chars |
description |
Yes | — | Catalog description shown to the parent model, at most 4 KiB |
subagent |
No | true |
Whether tuls agents may expose and launch this definition as a subagent |
provider |
Yes | — | openai, anthropic, openrouter, or custom |
model |
Yes | — | Provider model identifier |
base_url |
Custom: yes | provider default | Provider API prefix/root; rejected for first-class providers |
credential_env |
Custom: yes | provider default | Environment variable holding the API credential; rejected for first-class providers |
api |
Custom: yes | provider default | responses or anthropic-messages; rejected for first-class providers |
temperature |
No | provider default | 0..=2 for Responses, 0..=1 for Anthropic Messages |
reasoning_effort |
No | provider default | Wire-specific reasoning effort |
max_turns |
No | 32 |
Provider/tool loop limit, 1..=128 |
tools |
No | empty | Explicit child MCP grants; empty means no child tools |
disallowed_tools |
No | empty | Explicit child MCP denials; deny wins |
skills |
No | empty | Skills injected into the agent's system context |
mcp_servers |
No | empty | Named stdio or HTTP child MCP servers |
Unknown fields are rejected.
Set subagent: false on a leader definition that a surrounding AI client uses
as its main system prompt but that tuls agents must not expose or launch:
---
name: leader
description: Coordinates work and delegates specialized tasks
provider: openai
model: YOUR_OPENAI_MODEL
subagent: false
---
You are the main agent for this workspace.
Delegate focused work to specialists when useful.The default is true, so ordinary specialist definitions can omit the field.
subagent: false removes the name and description from the spawn_agent
schema and catalog, and direct calls using that name are rejected as unknown.
The file is still parsed and fully validated as a normal tuls agent definition.
This setting controls only eligibility in the tuls agents MCP server. It does
not prevent another AI client from reading the same Markdown file and using its
body as a primary/system prompt. Eligibility is static configuration; tuls
does not attempt to detect which agent the surrounding client is currently
using.
There is no instructions frontmatter field. Everything after the closing
--- of the YAML frontmatter is the agent's instruction text, and it must be
nonempty. Keep the body focused on task/system instructions; keep metadata in
the frontmatter.
An agent has no child MCP tools until tools grants them. Declaring a
child server in mcp_servers never grants access by itself — every grant is
explicit and per-server:
---
name: reviewer
description: Reviews workspace code without modifying files
provider: openai
model: YOUR_OPENAI_MODEL
tools:
- filesystem/*
mcp_servers:
filesystem:
type: stdio
command: tuls
args: ["filesystem", ".", "--allow", "filesystem.read"]
---
Review the requested code.For api: responses:
none
minimal
low
medium
high
xhigh
For api: anthropic-messages:
low
medium
high
xhigh
max
Only set a value that the selected upstream model/provider actually supports.
tuls validates the wire-level vocabulary, while the upstream provider remains
the authority on model-specific support.
provider |
Default base URL | Default credential_env |
Default api |
Authentication sent by tuls |
|---|---|---|---|---|
openai |
https://api.openai.com/v1 |
OPENAI_API_KEY |
responses |
Authorization: Bearer ... |
anthropic |
https://api.anthropic.com |
ANTHROPIC_API_KEY |
anthropic-messages |
x-api-key: ... + anthropic-version |
openrouter |
https://openrouter.ai/api/v1 |
OPENROUTER_API_KEY |
responses |
Authorization: Bearer ... |
custom |
none | none | none | Determined by api |
First-class providers (openai, anthropic, openrouter) reject base_url,
credential_env, and api overrides: each has a fixed endpoint, credential
variable, and wire contract. custom is the only way to reach a differently
shaped endpoint, and it requires base_url, credential_env, and api all
explicitly.
tuls appends the wire endpoint to base_url.
For Responses:
base_url + /responses
Examples:
https://api.openai.com/v1 -> https://api.openai.com/v1/responses
https://openrouter.ai/api/v1 -> https://openrouter.ai/api/v1/responses
For Anthropic Messages:
base_url + /v1/messages
Example:
https://api.anthropic.com -> https://api.anthropic.com/v1/messages
Do not include /v1 in a custom Anthropic-style base_url unless the target
API specifically expects a duplicated path segment.
Use this only for an endpoint that implements the OpenAI Responses API
shape used by tuls. Chat Completions compatibility alone is not sufficient.
provider: custom
model: vendor/model
base_url: https://gateway.example/api/v1
credential_env: GATEWAY_API_KEY
api: responsesThe credential is sent as:
Authorization: Bearer <credential>
Responses runs are stateless: every turn replays the full conversation history
as request input, instructions are sent as a developer item, and
store: false is set on each request.
provider: custom
model: vendor-model
base_url: https://gateway.example
credential_env: GATEWAY_API_KEY
api: anthropic-messagesThe credential is sent with the Anthropic-style headers used by the runtime. This mode is suitable only when the gateway accepts that authentication and Messages API contract.
Do not put provider API keys into an agent Markdown file.
This is intentionally invalid:
api_key: secretUse an environment-variable name instead:
credential_env: OPENROUTER_API_KEYand provide the secret to the tuls agents process:
export OPENROUTER_API_KEY='...'
tuls agents . --allow agents.runopenrouter is a first-class provider: the endpoint, credential variable, and
wire are fixed (https://openrouter.ai/api/v1 + /responses,
OPENROUTER_API_KEY, Responses), so agent files never repeat them.
export OPENROUTER_API_KEY='...'The API key is read by the tuls agents process when an agent is spawned.
It does not need to be copied into the child filesystem/fetch MCP processes.
Create:
.agents/agents/web-researcher.md
---
name: web-researcher
description: Researches public web sources through OpenRouter
provider: openrouter
model: openai/gpt-5.6-luna
reasoning_effort: high
max_turns: 32
tools:
- fetch/*
mcp_servers:
fetch:
type: stdio
command: tuls
args: ["fetch", "--allow", "network.fetch"]
---
Research the requested topic. Use fetch when evidence is needed and return
concise, source-oriented findings.The resulting provider request goes to:
POST https://openrouter.ai/api/v1/responses
Authorization: Bearer $OPENROUTER_API_KEY
OpenRouter model identifiers use provider-qualified names. Replace
openai/gpt-5.6-luna with the OpenRouter model you actually want to run.
From the workspace root:
tuls agents . --allow agents.runThe parent MCP client will discover web-researcher in the
spawn_agent catalog.
Conceptually:
{
"name": "web-researcher",
"task": "Compare the current Rust MCP ecosystem and identify the most relevant libraries."
}The call returns a task handle. Poll tasks/get with the returned taskId
until the task settles, then read the terminal task result for the agent's
agentId, name, and final response.
---
name: openrouter-implementer
description: Implements scoped code changes through OpenRouter
provider: openrouter
model: openai/gpt-5.6-luna
reasoning_effort: high
max_turns: 48
tools:
- filesystem/*
mcp_servers:
filesystem:
type: stdio
command: tuls
args:
- filesystem
- .
- --allow
- filesystem.read
- --allow
- filesystem.write
---
Implement the requested changes. Keep edits scoped to the workspace and
preserve project conventions.---
name: openrouter-investigator
description: Combines public-web research with read-only workspace inspection
provider: openrouter
model: openai/gpt-5.6-luna
reasoning_effort: high
tools:
- filesystem/*
- fetch/*
mcp_servers:
filesystem:
type: stdio
command: tuls
args: ["filesystem", ".", "--allow", "filesystem.read"]
fetch:
type: stdio
command: tuls
args: ["fetch", "--allow", "network.fetch"]
---
Investigate the task using repository evidence and public sources. Do not
modify workspace files.tuls sends Responses credentials using Bearer authentication, matching
OpenRouter's Responses endpoint at /api/v1/responses. The wire is fixed:
openrouter rejects api overrides. Do not expect
anthropic-messages for OpenRouter — that wire also changes the HTTP
authentication contract to Anthropic-style x-api-key, so it is intended for
endpoints that explicitly implement that contract.
Agents can use named child MCP servers, declared in YAML frontmatter.
Two transport types are supported:
stdio
http
mcp_servers:
filesystem:
type: stdio
command: tuls
args: ["filesystem", ".", "--allow", "filesystem.read"]The child process:
- runs locally as a direct exec of
command, with the OS identity of thetulsprocess; - starts in the agent workspace;
- has
kill_on_dropenabled; - receives a minimal inherited environment;
- does not implicitly inherit unrelated credentials;
- uses MCP
2026-07-28discovery.
If a child MCP server itself needs a credential, pass only that credential:
mcp_servers:
external:
type: stdio
command: external-mcp
args: ["serve"]
env: { EXTERNAL_API_KEY: "${EXTERNAL_API_KEY}" }${NAME} placeholders selectively expose individual variables from the
tuls agents process environment to the child; nothing else is inherited. A
missing variable fails the child startup.
mcp_servers:
issues:
type: http
url: https://mcp.example.com/mcp
headers: { Authorization: "Bearer ${ISSUE_MCP_TOKEN}" }HTTP child MCP clients use bounded timeouts and do not follow redirects.
Header values support the same ${NAME} environment interpolation.
Selector format:
server/tool
server/*
Example:
tools:
- filesystem/read_text_file
- filesystem/search_files
- fetch/*
disallowed_tools:
- fetch/some_tool_nameRules:
- Empty
toolsmeans no child MCP tools (default deny). server/*grants all tools advertised by that named child server.server/toolgrants one exact child tool.disallowed_toolsalways overridestools.- A selector referencing an unknown configured server is rejected.
- After connection, an exact selector referencing a tool not actually advertised by that child server is rejected.
- Authorization is based on this explicit policy, not child-provided read-only/destructive annotations.
- A child tool's reported
isErroris preserved and committed to the agent conversation as an error output; the run continues. - A call that times out or fails after dispatch has an ambiguous outcome (the tool may have executed) and the session is marked non-resumable.
- Completed sessions are resumable:
send_inputon a completed agent starts a new run that continues the retained conversation.
For built-in child servers, restrict both layers.
Good:
tools:
- filesystem/*
mcp_servers:
filesystem:
type: stdio
command: tuls
args: ["filesystem", ".", "--allow", "filesystem.read"]This means:
- the child filesystem process itself exposes only read operations;
- the agent policy grants only tools from that child server.
Do not rely on only one of those layers for high-risk tools.
Goal: inspect repository content without modifying it.
---
name: code-reviewer
description: Reviews repository content
provider: openai
model: YOUR_OPENAI_MODEL
tools:
- filesystem/*
mcp_servers:
filesystem:
type: stdio
command: tuls
args: ["filesystem", ".", "--allow", "filesystem.read"]
---
Review the requested code and report concrete issues.Goal: public-web access without filesystem or shell access.
---
name: web-researcher
description: Researches public web sources
provider: openai
model: YOUR_OPENAI_MODEL
tools:
- fetch/*
mcp_servers:
fetch:
type: stdio
command: tuls
args: ["fetch", "--allow", "network.fetch"]
---
Research the requested topic and return source-oriented findings.Goal: read and edit repository files without arbitrary process execution.
---
name: implementer
description: Implements scoped code changes
provider: openai
model: YOUR_OPENAI_MODEL
tools:
- filesystem/*
mcp_servers:
filesystem:
type: stdio
command: tuls
args:
- filesystem
- .
- --allow
- filesystem.read
- --allow
- filesystem.write
---
Implement the requested changes and keep edits scoped.Goal: run commands in addition to reading repository content.
---
name: test-runner
description: Runs tests and reads repository content
provider: openai
model: YOUR_OPENAI_MODEL
tools:
- filesystem/*
- shell/*
mcp_servers:
filesystem:
type: stdio
command: tuls
args: ["filesystem", ".", "--allow", "filesystem.read"]
shell:
type: stdio
command: tuls
args: ["shell", ".", "--allow", "process.execute"]
---
Run the requested tests and report results.This profile is substantially more privileged because shell is arbitrary
local process execution under the OS account. Prefer a real OS/container
sandbox when exposing it to an autonomous model.
For larger workflows, prefer multiple narrow agents instead of one all-powerful agent:
parent
├── researcher -> fetch only
├── reviewer -> filesystem.read only
└── implementer -> filesystem.read + filesystem.write
This reduces tool confusion and limits the blast radius of a bad tool choice.
Recommended repository structure:
project/
├── .agents/
│ ├── agents/
│ │ ├── leader.md
│ │ ├── reviewer.md
│ │ └── researcher.md
│ └── skills/
│ ├── rust-review/
│ │ ├── SKILL.md
│ │ └── checklist.md
│ └── release-check/
│ └── SKILL.md
├── src/
├── tests/
└── Cargo.toml
Agent discovery is recursive: any .md file with YAML frontmatter under
.agents/agents/ becomes an agent, so nested folders are fine. Use kebab-case
filenames (code-reviewer.md, not code_reviewer.md).
Set subagent: false in leader.md when it is intended only as the surrounding
client's main prompt. Specialists such as reviewer.md and researcher.md can
omit the field and remain spawnable by default.
tuls intentionally uses different naming conventions at different interface
boundaries instead of mixing styles within one interface.
| Interface | Convention | Example |
|---|---|---|
| Rust identifiers | snake_case |
max_length |
| Agent YAML frontmatter | snake_case |
disallowed_tools |
| Agent files | kebab-case |
code-reviewer.md |
| CLI flags | --kebab-case |
--user-agent |
| MCP JSON fields | camelCase |
maxLength |
| MCP tool names | snake_case |
read_text_file |
| Capabilities | domain.action |
filesystem.read |
| Built-in exact selectors | server/tool |
filesystem/read_text_file |
| Child MCP selectors | server/tool, server/* |
fetch/* |
Provider-facing tool names are internally qualified so tools from different
child MCP servers remain distinguishable. Policy configuration should always
use the documented server/tool form above.
tuls separates tool authorization from runtime containment.
- strict built-in capability/tool policy;
- default-deny child MCP tool policy for agents;
- tool removal from discovery plus call-time enforcement;
- strict public MCP JSON inputs;
- agent field validation;
- environment-based provider credentials;
- minimal environment inheritance for spawned commands/stdio child MCPs;
- bounded tool/provider/network outputs;
- filesystem root checks for filesystem operations;
- conservative public-network fetch policy;
- no automatic HTTP redirects in fetch/provider/child HTTP clients;
- explicit timeout handling.
tuls shell is not an OS sandbox. Directory roots do not restrict the
syscalls made by a spawned executable.
Filesystem path validation is designed to prevent ordinary path/symlink escape, but path validation is not a replacement for a kernel-enforced capability filesystem when hostile concurrent processes can mutate paths during an operation.
For hostile or highly autonomous workloads, deploy tuls inside a real sandbox
and restrict:
- writable filesystem paths;
- readable secret paths;
- network destinations;
- process execution;
- environment variables;
- operating-system identity and privileges.
See SECURITY.md for the security boundary and deployment
recommendations.
Selected implementation limits:
| Area | Limit / behavior |
|---|---|
| Generic tool text result | 64 KiB |
| Fetch raw response body | 8 MiB |
| Fetch URL | 8,192 characters |
Fetch maxLength |
1–50,000 characters (default 5,000) |
| Fetch request timeout | 30 seconds |
| Fetch DNS resolution timeout | 10 seconds |
| Fetch redirects | disabled |
| Media file input | 1 MiB |
| Text file input | 8 MiB |
| Multi-file read batch | 32 files |
edit_file batch |
1,024 operations and 8 MiB total edit text |
| Shell stdout capture | 8 KiB |
| Shell stderr capture | 8 KiB |
| Shell default timeout | 120 seconds |
| Shell maximum timeout | 600 seconds |
| Provider response body | 8 MiB |
Agent spawn task / send_input message |
256 KiB each |
| Agent turn execution limit | 30 minutes |
| Agent task TTL | 35 minutes |
| Agent result size | 24 KiB |
| Retained idle agent sessions | 64 |
| Discovered agents | 256 |
| Agent catalog description | 4 KiB |
| Agent file / generated catalog | 1 MiB / 64 KiB |
| Discovered skills | 256 |
| Skill description / generated catalog | 4 KiB / 64 KiB |
| Activated skill / resource manifest | 1 MiB / 64 KiB |
| Skills per agent | 32 |
| Built agent context (instructions + skills) | 1 MiB |
| Agent markdown file | 1 MiB |
| Default provider turns | 32 |
| Maximum provider turns | 128 |
| Agent runtime concurrent capacity | 8 |
| Memory file | 8 MiB |
| Individual memory text field | 16 KiB |
These limits are safety boundaries, not tuning recommendations. Applications that need materially larger payloads should change them deliberately and review the impact on model context size, memory use, latency, and denial-of-service exposure.
Policy selectors are strict.
Correct:
filesystem.read
filesystem/read_text_file
Incorrect examples:
filesystem-read
filesystem.read_text_file
filesystem/read-file
Use a capability from the capability table or an exact server/tool ID.
This is expected when tools is empty (default deny).
Declaring:
mcp_servers:
filesystem:
type: stdio
command: tuls
args: ["filesystem", ".", "--allow", "filesystem.read"]does not grant access. Add an explicit policy:
tools:
- filesystem/*The part before / must exactly match a key under mcp_servers.
This must match:
tools:
- repo/*
mcp_servers:
repo:
type: stdio
command: repo-mcp
args: ["serve"]An exact selector points to a tool that the child did not advertise.
Check both:
- the exact child tool name;
- whether the child process's own
--allow/--denypolicy disabled it.
OPENROUTER_API_KEY is the default credential variable for
provider: openrouter, so it must exist in the environment of the
tuls agents process.
Check before launching the MCP client/process:
printenv OPENROUTER_API_KEYFor GUI MCP clients, configure secrets using that client's environment/secret mechanism rather than assuming the GUI inherited your terminal session.
openrouter is first-class: the request goes to
https://openrouter.ai/api/v1/responses with Bearer auth and the credential
from OPENROUTER_API_KEY. Overrides are rejected, so a custom-style
base_url/credential_env/api in the agent file is a configuration error.
Verify that OPENROUTER_API_KEY is set in the tuls agents process and that
the selected OpenRouter model supports the behavior needed by the agent,
especially tool calling and any requested reasoning parameters.
api: responses requires a Responses-compatible endpoint, not merely
an OpenAI Chat Completions-compatible endpoint.
stdio child MCP processes deliberately start with a minimal environment. Pass required variables explicitly:
env: { TOKEN: "${TOKEN}" }Remember:
programis an executable name;argsare separate argv entries;- no shell syntax is interpreted unless you explicitly run a shell;
- spawned processes use a reduced environment.
For example, use:
{
"program": "cargo",
"args": ["test"]
}not:
{
"program": "cargo test"
}For filesystem and shell, relative paths resolve against the first root:
tuls filesystem /work/project /work/sharedHere src/main.rs resolves relative to /work/project.
That is the default --network public policy.
For an explicitly trusted deployment that needs private network access:
tuls fetch \
--network unrestricted \
--allow network.fetchTreat unrestricted network access as a meaningful privilege increase.
Required commands:
cargo fmt --check
cargo check
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo package
cargo build --releaseThe crate forbids unsafe Rust and denies several panic-oriented Clippy patterns in non-test builds.
Live end-to-end tests run against the real compiled binary and a real model:
export OPENROUTER_API_KEY='...'
export TULS_LIVE_MODEL='openai/gpt-5.6-luna'
TULS_LIVE=1 cargo test --test live_provider -- --nocaptureTULS_LIVE is the required gate; without it the live tests report themselves
as skipped and cargo test stays green offline.
src/
├── agents/
│ ├── activity.rs
│ ├── child_mcp.rs
│ ├── definition.rs
│ ├── discovery.rs
│ ├── markdown.rs
│ ├── provider.rs
│ ├── runtime.rs
│ └── timeouts.rs
├── fetch/
│ ├── http.rs
│ └── mod.rs
├── fs/
│ ├── edit.rs
│ ├── format.rs
│ ├── search.rs
│ └── mod.rs
├── memory/
│ ├── graph.rs
│ └── mod.rs
├── shell/
│ ├── drain.rs
│ └── mod.rs
├── skills/
│ ├── discovery.rs
│ ├── manifest.rs
│ ├── parser.rs
│ └── mod.rs
├── support/
│ ├── access.rs
│ └── mod.rs
├── cli.rs
├── main.rs
└── policy.rs
Tests live beside their corresponding modules in tests.rs files.
This example gives a parent model access only to the agents orchestration surface. The spawned agent can read repository files and fetch public web content, but cannot write files or execute arbitrary processes.
project/
└── .agents/
└── agents/
└── investigator.md
---
name: investigator
description: Investigates repository issues using read-only files and public web research
provider: openrouter
model: openai/gpt-5.6-luna
reasoning_effort: high
max_turns: 32
tools:
- filesystem/*
- fetch/*
mcp_servers:
filesystem:
type: stdio
command: tuls
args: ["filesystem", ".", "--allow", "filesystem.read"]
fetch:
type: stdio
command: tuls
args: ["fetch", "--allow", "network.fetch"]
---
Inspect repository evidence first. Use public web research only when needed.
Do not modify files and do not execute local programs.cd /absolute/path/to/project
export OPENROUTER_API_KEY='...'
tuls agents . --allow agents.run| Layer | Granted | Not granted |
|---|---|---|
| Parent MCP surface | agents.run |
filesystem, fetch, memory, shell directly |
| Agent child policy | filesystem/*, fetch/* |
shell, memory, undeclared child servers |
| Child filesystem server | filesystem.read |
filesystem.write |
| Child fetch server | network.fetch, public-network default |
private network, redirects |
| OS process boundary | normal account permissions | not sandboxed by tuls |
This layered model is the recommended pattern: grant the model only the tool families it needs, and independently restrict each child MCP process to the minimum operation set required for its role.