# factorly.yaml or .factorly/factorly.yaml
tools_dir: ./tools # optional, scan a custom directory for tool files
# (the loader already auto-discovers
# .factorly/tools/ when the config lives in
# .factorly/, so this line is only needed for
# non-standard layouts)
disabled_commands: [vault, exec] # optional, block specific CLI commands
disable_builtins: true # optional, disable all factorly.* built-in tools
disabled_builtins: # optional, disable specific built-in tools
- factorly.shell
- factorly.clipboard
vault_backends: # optional, external secret managers
op: # backend name → use as {{op:KEY}}
type: cli
get:
command: op
args: ["read", "op://Development/{{key}}"]
list: # optional
command: op
args: ["item", "list", "--format=json"]
oauth_providers: # shared across tools
google:
client_id: "{{vault:GOOGLE_CLIENT_ID}}"
client_secret: "{{vault:GOOGLE_CLIENT_SECRET}}"
auth_url: https://accounts.google.com/o/oauth2/v2/auth
token_url: https://oauth2.googleapis.com/token
scopes: ["https://www.googleapis.com/auth/drive.readonly"]
tools:
<tool-name>:
type: cli | rest | mcp | workflow | builtin # required (builtin is internal-only)
description: "..." # optional, shown to agent
# For CLI commands:
command: curl # executable to run
args: ["-s", "{{url}}"] # {{param}} placeholders are substituted
stdin: "{{input}}" # optional, pipe to subprocess stdin
interactive: true # optional, connect to terminal (TTY)
# For MCP servers (stdio — spawn subprocess):
command: npx # executable to start the server
args: ["@org/server-name"] # arguments
env: # environment variables
KEY: {{vault:SECRET}}
HOME: "{{env:HOME}}" # forward from host env
timeout: 30s # execution timeout (e.g. "10s", "2m"; default 30s for CLI)
max_output: 50000 # max output bytes (default 50000)
compress: ["all"] # compression hints: "json", "logs", or "all"
# For MCP servers (HTTP — connect to remote):
url: http://host:3000/mcp # server URL
# For REST APIs:
base_url: https://api.example.com
method: GET # GET, POST, PUT, PATCH, DELETE
path: /items/{{id}} # {{param}} placeholders in path
body: | # optional, JSON body template with {{param}} placeholders
{"model":"{{model}}","messages":[{"role":"user","content":"{{prompt}}"}]}
headers: # static headers (optional)
Accept: application/json
auth: # optional
type: bearer # bearer, basic, header, or oauth
token: {{vault:API_KEY}} # for bearer
# header: X-Api-Key # for header type
# value: {{vault:KEY}} # for header type
# OAuth (inline):
# type: oauth
# client_id: {{vault:CLIENT_ID}}
# client_secret: {{vault:CLIENT_SECRET}}
# auth_url: https://provider.com/oauth/authorize
# token_url: https://provider.com/oauth/token
# scopes: ["read", "write"]
# token_key: provider_oauth # vault key for token bundle
# OAuth (provider reference):
# type: oauth
# provider: google # references oauth_providers section
env_isolation: strict # optional, restrict child env to essentials
parameters:
- name: id
in: path # path, query, header, or body
required: true
- name: limit
in: query
type: integer # string (default), text, integer, number, boolean, json
default: 100 # default value if not provided by caller
min: 1 # clamp to minimum (integers and numbers)
max: 100 # clamp to maximum (integers and numbers)
- name: state
type: string
enum: [open, closed, all] # reject values not in list
- name: owner
type: string
pattern: "^[a-zA-Z0-9-]+$" # reject values not matching regex
min_length: 1 # reject strings shorter than this
max_length: 39 # truncate strings longer than this
# For workflows (sequential tool pipelines):
steps:
- tool: github.list_repos # tool to call (must exist in config)
params: { owner: "{{org}}" } # {{var}} substituted from input + stored outputs
store: repos # save output as variable for later steps
- tool: anthropic.ask
params:
prompt: "Summarize: {{repos}}"
shadow: # optional, oversight rules
deny: [dangerous_action]
confirm: true # or ["specific_action"]
rate_limit: 100/hour
log_params: [id]
allow_patterns: [] # override denied shell patterns (built-in tools)
allow_paths: [] # override denied file paths (built-in tools)
allow_urls: [] # override denied URLs (built-in tools)
filter: # optional, command-aware output filtering
match_output: # short-circuit: replace output with message
- pattern: "Build complete"
message: "ok (build succeeded)"
unless: "error|Error|FAIL"
strip_lines: # drop lines matching any regex
- "^\\s*$"
- "^\\[INFO\\]"
keep_lines: # keep only matching lines (mutually exclusive with strip_lines)
- "^FAIL"
- "^PASS"
replace: # regex substitutions (applied in order)
- pattern: "/home/\\w+"
replacement: "~"
head_lines: 10 # keep first N lines
tail_lines: 5 # keep last N lines (inserts omission marker)
max_lines: 50 # absolute line count cap
json_path: "$.content[0].text" # JSONPath extraction (full spec)
pipe: # pipe output through external tool (cli, rest, mcp)
type: cli # cli (default), rest, or mcp
command: jq
args: [".items[:10]"]
timeout: 5s
| Type | Description |
|---|---|
string |
Single-line text (default) |
text |
Multi-line text — same as string at the protocol level, but renders as a textarea in the UI |
integer |
Whole number — supports min/max clamping |
number |
Decimal number — supports min/max clamping |
boolean |
true or false |
json |
Arbitrary JSON value |
The text type is useful for parameters that accept long-form content like prompts, code snippets, or message bodies. It behaves identically to string in the MCP schema and execution — the difference is only in UI rendering (multiline textarea vs single-line input).
tools:
summarize:
type: rest
base_url: https://api.openai.com/v1
method: POST
path: /chat/completions
auth:
type: bearer
token: "{{vault:OPENAI_KEY}}"
parameters:
- name: content
type: text # renders as textarea in the UI
required: true
description: "Text to summarize"
- name: model
type: string
default: "gpt-4o"Use {{env:ENV_VAR}} for environment variables or {{vault:KEY}} for encrypted vault secrets. Both are resolved at startup before the agent sees anything.
Add a | after the key to specify a fallback if the value is not found:
tools:
api.call:
type: rest
base_url: "{{env:API_URL|https://api.example.com}}"
method: GET
path: /items
auth:
type: bearer
token: "{{vault:API_KEY|sk-default-dev-key}}"Defaults work with all reference types:
# Vault — use fallback if key not in vault
token: "{{vault:TOKEN|default-value}}"
# Environment — use fallback if env var not set
home: "{{env:HOME|/tmp}}"
# External backends — use fallback if key not found
secret: "{{op:vault/item|fallback}}"
# Parameters — use fallback if param not provided by caller
args: ["--limit", "{{limit|100}}", "--host", "{{host|localhost}}"]If no default is specified and the value is missing, Factorly returns an error.
To pass a literal {{...}} without resolution, escape with a backslash: \{{vault:TOKEN}} passes through as {{vault:TOKEN}}.
Parameters are routed by their in field. When in is omitted, defaults to query for GET/DELETE or body for POST/PUT/PATCH.
The type field controls how body parameters are serialized in JSON:
| Type | JSON output | Example |
|---|---|---|
string (default) |
Quoted | "claude-sonnet-4-6" |
integer |
Unquoted number | 1024 |
number |
Unquoted number | 0.95 |
boolean |
Unquoted | true |
json |
Raw passthrough | [{"role":"user","content":"hello"}] |
When multiple parameters have in: body, they are merged into a single JSON object using their types for serialization.
Parameters can have validation rules that catch broken calls before execution. Where possible, values are coerced (fixed) rather than rejected — the audit log records what was changed.
| Rule | Applies to | Behavior |
|---|---|---|
min / max |
integer, number |
Clamp to boundary (coerce) |
min_length |
string |
Reject if too short |
max_length |
string |
Truncate if too long (coerce) |
pattern |
string |
Reject if regex doesn't match |
enum |
string |
Reject if not in allowed list |
Type validation is always active when type is set:
integer— must parse as int; floats like"3.0"are truncated to3number— must parse as floatboolean—"true"/"1"/"yes"→"true","false"/"0"/"no"→"false"json— must be valid JSON
Coercion vs rejection: Min/max clamping, max_length truncation, and type normalization are coerced silently. Pattern, enum, min_length, and unparseable types are rejected with an error returned to the caller (the LLM gets the error and can retry).
Audit trail: When parameters are coerced, the log entry includes original_params with pre-coercion values and shadow_action: "modified". Rejected calls log shadow_action: "invalid".
parameters:
- name: temperature
type: number
min: 0
max: 2
default: 0.7
- name: max_tokens
type: integer
min: 1
max: 4096
- name: format
type: string
enum: [json, text, csv]
- name: query
type: string
pattern: "^[a-zA-Z0-9 ]+$"
max_length: 500For REST tools with complex JSON bodies, use the body field instead of individual body parameters. Placeholders are substituted directly:
tools:
anthropic.ask:
type: rest
base_url: "https://api.anthropic.com"
method: POST
path: /v1/messages
body: |
{"model":"{{model}}","max_tokens":{{max_tokens}},"messages":[{"role":"user","content":"{{prompt}}"}]}
parameters:
- name: prompt
description: The question for Claude
required: true
- name: model
default: claude-sonnet-4-6
- name: max_tokens
default: "1024"factorly call anthropic.ask --prompt "What is the capital of France?"When body is set, parameter in fields are ignored for body routing — all substitution happens in the template.
CLI tools can pipe a parameter to the subprocess's stdin using the stdin field with {{param}} placeholders:
tools:
jq.filter:
type: cli
command: jq
args: ["{{filter}}"]
stdin: "{{input}}"
clipboard.copy:
type: cli
description: "Copy text to the system clipboard"
command: pbcopy # macOS (use xclip -selection clipboard on Linux)
stdin: "{{text}}"factorly call jq.filter --filter ".name" --input '{"name":"Jordan","role":"VP Eng"}'
factorly call clipboard.copy --text "copied to clipboard"CLI tools that need a TTY (database shells, SSH, REPLs) can set interactive: true to connect the subprocess directly to your terminal:
tools:
db.shell:
type: cli
command: psql
args: ["-h", "localhost", "-U", "{{user}}", "{{database}}"]
interactive: true
env:
PGPASSWORD: {{vault:DB_PASSWORD}}
ssh.connect:
type: cli
command: ssh
args: ["{{host}}"]
interactive: truefactorly call db.shell --user admin --database myapp
# Drops into an interactive psql session with vault-injected password
factorly call ssh.connect --host prod-server
# Opens an interactive SSH sessionInteractive tools connect stdin/stdout/stderr directly to your terminal. Output is not captured (Result is empty), but the call is still logged with tool name, params, duration, and exit code. Interactive mode only works via factorly call, not through factorly serve (MCP has no terminal).
Control how tool output is compressed, filtered, and truncated before it reaches the agent.
tools:
big-query:
type: cli
command: bq
args: ["query", "{{sql}}"]
max_output: 100000 # bytes — overrides FACTORLY_MAX_OUTPUT
compress: ["json", "logs"] # or "all" for everythingOutput passes through these stages in order:
- ANSI strip — always on, removes color/formatting escape codes
- Whitespace normalize — always on, collapses 3+ blank lines to 2
- Filter — per-tool, command-aware filtering (see below)
- JSON compact — when
compressincludesjsonorall - Log dedup — when
compressincludeslogsorall - Byte truncation — when
max_outputis set, 60% head + 40% tail
Per-tool output filters for command-aware compression — strip noise lines, short-circuit verbose output to summaries, regex replace, and line-based truncation. Factorly ships with 27 built-in filters for common commands (git, make, npm, go, cargo, pytest, pip, apt, brew, docker, kubectl, terraform) that apply automatically.
See Output Filters for the full filter schema, stages, and built-in filter list.
Set FACTORLY_MAX_OUTPUT to apply a default max across all tools. Per-tool max_output takes precedence.
Compression and truncation savings are recorded in the audit log (original_bytes, processed_bytes). Run factorly tools status to see an output savings summary.
By default, child processes inherit the full parent environment (standard behavior). Set env_isolation: strict to restrict to a minimal set: PATH, HOME, USER, LANG, TERM + explicit env: entries only.
tools:
deploy:
type: cli
command: ./deploy.sh
env_isolation: strict # minimal env (opt-in)
env:
AWS_PROFILE: "{{env:AWS_PROFILE}}" # forwarded from host
AWS_REGION: "{{env:AWS_REGION}}" # forwarded from host
DEPLOY_ENV: staging # explicit value
API_KEY: "{{vault:DEPLOY_KEY}}" # from encrypted vaultWithout env_isolation: strict, the env: field adds or overrides vars on top of the full parent environment.
Restrict which Factorly CLI commands are available. Useful for locking down access in shared or deployed environments.
disabled_commands:
- vault # prevent direct vault access
- exec # prevent ad-hoc command execution
- wrap # prevent wrapping arbitrary MCP serversDisabled commands return a clear error: command "vault" is disabled in .factorly/factorly.yaml. Commands not in the list work normally.
Supported commands: call, exec, wrap, serve, sync, logs, vault, auth.
Workflows chain multiple tool calls into a single governed pipeline. Define with type: workflow and a steps: array:
tools:
ci.check:
type: workflow
description: Run lint and tests, then notify
parameters:
- name: channel
default: "#ci"
steps:
- tool: factorly.shell
params: { command: "make lint" }
store: lint
- tool: factorly.shell
params: { command: "make test" }
store: tests
- tool: slack.post_message
params:
channel: "{{channel}}"
text: "CI passed: {{tests}}"Step fields:
| Field | Description |
|---|---|
tool |
Tool name to call (must exist in config) |
params |
Parameters to pass (supports {{var}} substitution) |
store |
Save step output as a named variable for later steps |
Variable sources (in order of precedence):
- Stored outputs from previous steps (
store: varname) - Input parameters from the caller
- Parameter defaults
Behavior:
- Steps execute sequentially, each through the full proxy (oversight, logging, output filters)
- On failure, remaining steps are skipped — output shows
"status": "failed" - State is persisted to
.factorly/workflows/<run-id>.jsonafter each step - Any tool type can be a step: CLI, REST, MCP, or another workflow
See Workflow Example for full usage details.
For CLI tools, parameters are automatically inferred from {{placeholder}} patterns in args and stdin.
Add a shadow block to any tool to control what agents can do:
tools:
github:
type: mcp
command: npx
args: ["@modelcontextprotocol/server-github"]
env:
GITHUB_TOKEN: "{{vault:GITHUB_TOKEN}}"
shadow:
deny: [delete_repository, delete_branch]
confirm: [merge_pull_request, create_release]
rate_limit: 100/hour
log_params: [repo, branch, title]Block specific tools or sub-tools. The call is rejected immediately with a clear error.
shadow:
deny: [delete_repository, delete_branch]For MCP servers, deny lists reference sub-tool names without the server prefix. deny: [delete_repository] on a github MCP server blocks github.delete_repository.
Require human approval before execution.
- CLI (
factorly call): prompts on stderr:⚠ Tool "x" requires confirmation. Proceed? (y/n) - MCP (
factorly serve): uses MCP elicitation — the client (Claude Code) shows the confirmation prompt in the chat UI
# Confirm specific tools
shadow:
confirm: [merge_pull_request, create_release]
# Confirm every call
shadow:
confirm: trueLimit how many times a tool can be called within a time window. Prevents runaway agents. Uses a token bucket algorithm for smooth throttling — no window-boundary bursts.
shadow:
rate_limit: 100/hour # 100 calls per hour
rate_limit: 10/min # 10 calls per minute
rate_limit: 5/sec # 5 calls per secondRate limit state persists across factorly call invocations. Project configs store buckets at <project>/.factorly/ratelimit.json; the global config falls back to ~/.config/factorly/ratelimit.json. Reset by deleting that file.
Always-on — no configuration needed. Factorly fingerprints identical calls (same tool + same parameters) within a 300-second sliding window and applies three tiers:
| Repeat count | Behavior |
|---|---|
| 1–3 | Normal execution |
| 4–11 | Warning logged, execution continues |
| 12+ | Call blocked |
Highlight specific parameters in the call log for audit visibility.
shadow:
log_params: [repo, branch, title]These params appear in the highlight_params field of the JSONL log entry, making it easy to search and filter audit trails.
Shadow works on any tool type — CLI, REST, or MCP:
tools:
stripe.charge:
type: rest
base_url: https://api.stripe.com
method: POST
path: /v1/charges
auth:
type: bearer
token: "{{vault:STRIPE_KEY}}"
shadow:
confirm: true
rate_limit: 10/min