A code tool is a Factorly tool whose body is a Go script. The script runs inside a sandboxed yaegi interpreter and can call any other registered tool through factorly.Call. Code tools shine when you need branching, loops, data shaping, or composition across multiple tools — things that get awkward as declarative workflows.
There are two flavors:
type: codetool — power-user authoring. You write the Go body in YAML; Factorly compiles it once at registration. Lives at.factorly/<file>.yamllike any other tool.factorly.codebuiltin — agent-authored. The script is a parameter; an LLM composes it at call time. Same sandbox, same SDK.
Both share the same engine, the same factorly.Call SDK, and the same audit trail.
Every script declares one function:
func Run(params map[string]string) (any, error)paramsis the tool's call-time arguments. Always non-nil.- Return value becomes the tool's output:
string→ passes through verbatim- anything else →
json.Marshal'd nil→ empty output
- Return error → tool reports
ExitCode: 1with the error message - Panic → recovered, reported as
ExitCode: 2
The script must start with a package <name> declaration (the name itself doesn't matter — package main works).
A minimal stdlib subset, plus the factorly host package:
import (
"fmt"
"strings"
"strconv"
"time"
"encoding/json"
"errors"
"factorly" // host-provided SDK
)Everything else (notably os, net, reflect, unsafe) is denied at compile time. Scripts touch the outside world only through factorly.Call.
package factorly
// Call another registered Factorly tool. Returns the same Result
// shape the proxy uses internally; check IsError() to detect
// tool-level failures and check err for infrastructure failures.
func Call(name string, params map[string]string) (*Result, error)
// Snapshot of currently-registered, non-hidden tools.
// Stable for the duration of one Run.
func ListTools() []ToolInfo
type Result struct {
Output string
Error string
ExitCode int
Duration time.Duration
}
func (r *Result) IsError() bool
type ToolInfo struct {
Name string
Description string
Parameters []ParamInfo
}
type ParamInfo struct {
Name string
Type string // "string", "boolean", etc. — empty defaults to "string"
Required bool
Description string
Default string
}# .factorly/my-tools.yaml
tools:
greet:
type: code
description: Greet someone by name
parameters:
- name: name
required: true
default: world
code: |
package main
func Run(params map[string]string) (any, error) {
return "hello " + params["name"], nil
}Call it:
factorly call greet --name alice
# hello aliceIf the script fails to compile, factorly tools lists the tool but the error is logged at startup and any call returns script failed to compile: <yaegi message>. Fix the source, save, the next invocation re-runs.
A code tool that fetches JSON and reshapes it:
tools:
github.zen:
type: code
description: Fetch the GitHub Zen koan and tag it with a timestamp
code: |
package main
import (
"errors"
"fmt"
"time"
"factorly"
)
func Run(params map[string]string) (any, error) {
res, err := factorly.Call("factorly.fetch", map[string]string{
"url": "https://api.github.com/zen",
})
if err != nil {
return nil, err
}
if res.IsError() {
return nil, errors.New(res.Error)
}
return fmt.Sprintf("[%s] %s", time.Now().Format(time.RFC3339), res.Output), nil
}Every inner factorly.Call re-enters the proxy and goes through its full shadow/vault/audit machinery. The script can only reach what's already exposed as a tool.
The builtin's parameters are the script:
factorly call factorly.code \
--code 'package main
func Run(p map[string]string) (any, error) { return "hi " + p["who"], nil }' \
--params '{"who":"world"}'
# hi worldSame engine, same SDK. The params argument is a JSON object; its keys/values become the params map[string]string the script's Run receives.
The builtin is registered in stdio mode only (same gating as factorly.shell). HTTP-mode MCP servers don't expose it.
When an agent-authored script via factorly.code does something useful that you want to keep — instead of having the agent re-write it next time — promote the run into a named type: code tool. The source comes from the audit log; the parameter list is inferred from the params the script was invoked with (their run-time values become defaults).
From the CLI:
# Find the run you want to promote (source_sha shows in `factorly logs`).
factorly logs --tail 5
# Promote it. The SHA prefix only needs to be unambiguous (>= 4 chars).
factorly tools promote --from-sha 8e3c --name trello.factorly_cardsFlags:
--from-sha <prefix>—source_shaprefix of the audit entry. Most-recent matching run wins; ambiguous prefixes (multiple distinct scripts share the prefix) error out so you can narrow it.--name <name>— the new tool's name. Follow thecategory.actionconvention.--description <text>— optional. Defaults to "Promoted from factorly.code run on YYYY-MM-DD (sha …)".--overwrite— replace an existing tool of the same name.--no-confirm— opt out ofshadow.confirm: true(the default for promoted tools).
The promote step compile-checks the script before writing the YAML — a broken script never lands as a registered tool. The output is a normal type: code tool in .factorly/<name>.yaml, ready to edit in the UI like any other.
From the UI:
/history shows a Save as tool button on every successful factorly.code row. Clicking it opens a form prefilled with the inferred parameters and source preview. Submit lands you on the new tool's edit page so you can polish the description and parameter docs.
After promotion, calling the script is just factorly call trello.factorly_cards --board_id … — and the agent can discover the new tool via factorly.ListTools() on its next run.
Code tools are tool calls, full stop. The shadow layer treats them like any other tool:
factorly.code:
type: builtin
shadow:
confirm: true # human approval on every invocation
max_calls: 50 # cap inner factorly.Call invocations per script run
rate_limit: 10/min # standard rate limiting appliesAnd inner factorly.Call invocations are subject to their own tool's shadow rules — so a script trying to call a confirm: true tool still triggers the confirm prompt for each call.
| Setting | Default | Configured via |
|---|---|---|
| Wall-clock timeout | per-tool timeout: field (or unlimited) |
tools.X.timeout: "30s" |
| Inner-call cap | 100 | tools.X.shadow.max_calls: 200 |
| Output size cap | per-tool max_output (or unlimited) |
tools.X.max_output: 50000 |
Exceeding max_calls returns a Go error from factorly.Call, which the script can handle or propagate.
Every call to a code tool stamps a source_sha field on the audit log entry — a SHA-256 of the script body that ran. For type: code tools the hash is over the stashed source; for factorly.code it's over the agent-supplied code param. Inner calls inherit the parent's agent ID and are tagged with iface: code so the audit log can distinguish "script-driven" from "directly invoked" calls.
View the trail:
factorly logs --tail 5// String returns pass through verbatim:
return "ok", nil
// Output: ok
// Structured returns are JSON-marshaled:
return map[string]int{"count": 42}, nil
// Output: {"count":42}
// Slices, structs, anything Marshalable works:
return []string{"a", "b", "c"}, nil
// Output: ["a","b","c"]If you want JSON output, return the struct or map directly — don't json.Marshal it yourself, the provider does that for you. (You will see double-encoding if you do.)
All params are strings on the wire. Use strconv to coerce, and fall back to the declared default if you want:
limit := 10
if v := params["limit"]; v != "" {
if n, err := strconv.Atoi(v); err == nil {
limit = n
}
}If the tool's YAML declares default: "10" on the param, the proxy populates params["limit"] before the script runs, so the conditional above is enough.
A script that picks a tool based on what's registered:
for _, t := range factorly.ListTools() {
if strings.HasPrefix(t.Name, "gmail.") {
// ...
}
}Hidden tools (hidden: true) are excluded from ListTools — they're still callable by name if the script hardcodes it, but the agent-facing discovery surface treats them as private.
A script that paginates through a list:
func Run(params map[string]string) (any, error) {
var all []string
cursor := ""
for {
params := map[string]string{}
if cursor != "" {
params["cursor"] = cursor
}
res, err := factorly.Call("api.list", params)
if err != nil {
return nil, err
}
// ... parse, append to all, update cursor, break when empty
if cursor == "" {
break
}
}
return all, nil
}Loop detection on the outer code tool fires per the usual fingerprinting; loop detection on inner factorly.Call invocations fires per those fingerprints. The max_calls budget puts a hard ceiling on inner volume regardless of fingerprints.
See the example pages for runnable scripts:
- 34: Hello Code Tool — minimum viable script with a parameter
- 35: Fetch and Transform — call
factorly.fetch, parse JSON, return a field - 36: Cross-tool Composition — script that calls multiple sibling tools and joins results
- 37: factorly.code Builtin — the agent-authored entry point
code_file:— referencing external.gofiles. Today the body must be inline.- Confirm-on-source-change for
factorly.code— approve once per SHA, replay within a window. Today the user-side path isshadow: confirm: truewhich prompts every call. - JS engine — QuickJS-backed
type: codejsandfactorly.codejswere explored; deferred until the Go path gets real-world use.