Plugins
Plugins extend TF Code by hooking into events, adding custom tools, and modifying behavior.
Use a Plugin
From Local Files
Place .ts or .js files directly in a plugin/ or plugins/ directory (both names work):
.tfcode/plugins/— Project-level.tfcode/plugin/— Project-level (alternative name)~/.config/tfcode/plugins/— Global~/.config/tfcode/plugin/— Global (alternative name)~/.tfcode/plugins/— Home directory
Files must be top-level in the directory (subdirectories are not scanned). Only .ts and .js extensions are loaded (not .mjs, .cjs, .mts).
From npm
Specify npm packages in your config. Supports version pinning:
{
"plugin": [
"opencode-helicone-session",
"@my-org/custom-plugin",
"some-plugin@1.2.0"
]
}
npm plugins are installed automatically at startup and cached in ~/.cache/tfcode/node_modules/ (Linux) or ~/Library/Caches/tfcode/node_modules/ (macOS).
Load Order
- Internal auth plugins (always loaded)
- Builtin opt-in plugins (from
builtinconfig) - Remote well-known config plugins
- Global config (
~/.config/tfcode/tfcode.json) - Custom config (
OPENCODE_CONFIGenv var) - Project config (
tfcode.json) .tfcodedirectory plugins (home + project)- Inline config (
OPENCODE_CONFIG_CONTENTenv var) - Managed config (enterprise)
Plugin arrays from multiple config sources are concatenated and deduplicated by name. When a local plugin and an npm plugin share the same name, the one from the higher-priority source wins.
Create a Plugin
A plugin is a JavaScript/TypeScript module that exports one or more async functions returning hooks.
Basic Structure
.tfcode/plugins/example.js
export const MyPlugin = async ({
project,
client,
$,
directory,
worktree,
serverUrl,
}) => {
return {
// Hook implementations
};
};
The plugin function receives:
| Field | Description |
|---|---|
project | Current project information (id, worktree, etc) |
directory | Current working directory |
worktree | Git worktree path |
client | TF Code SDK client for interacting with the AI |
$ | Bun's shell API for executing commands |
serverUrl | URL of the running TF Code server |
TypeScript
import type { Plugin } from "@toothfairyai/tfcode-plugin";
export const MyPlugin: Plugin = async ({
project,
client,
$,
directory,
worktree,
serverUrl,
}) => {
return {};
};
Dependencies
Add a package.json in the same directory as your plugin files. TF Code auto-injects @toothfairyai/tfcode-plugin as a dependency and runs bun install at startup. A .gitignore is also auto-created to exclude node_modules, package.json, bun.lock.
.tfcode/package.json
{
"dependencies": {
"shescape": "^2.1.0"
}
}
Hooks
Plugins return an object with one or more hook implementations.
Event Hook
Subscribe to bus events:
export const MyPlugin = async () => {
return {
event: async ({ event }) => {
if (event.type === "session.idle") {
console.log("Session completed");
}
},
};
};
Available event types:
- Command:
command.executed - File:
file.edited,file.watcher.updated - LSP:
lsp.client.diagnostics,lsp.updated - Message:
message.part.removed,message.part.updated,message.removed,message.updated - Permission:
permission.asked,permission.replied - Server:
server.connected - Session:
session.created,session.compacted,session.deleted,session.diff,session.error,session.idle,session.status,session.updated - Tool:
tool.execute.after,tool.execute.before - TUI:
tui.prompt.append,tui.command.execute,tui.toast.show
Config Hook
Receive config updates:
config: async (input) => {
// input is the current Config object
};
Chat Hooks
Modify messages, parameters, and headers before LLM calls:
"chat.message": async (input, output) => {
// input: { sessionID, agent?, model?, messageID?, variant? }
// output: { message, parts }
},
"chat.params": async (input, output) => {
// input: { sessionID, agent, model, provider, message }
// output: { temperature, topP, topK, options }
},
"chat.headers": async (input, output) => {
// input: { sessionID, agent, model, provider, message }
// output: { headers }
},
Command Hook
Intercept commands before execution:
"command.execute.before": async (input, output) => {
// input: { command, sessionID, arguments }
// output: { parts }
},
Tool Hooks
Before and after tool execution, plus tool definition modification:
"tool.execute.before": async (input, output) => {
// input: { tool, sessionID, callID }
// output: { args }
},
"tool.execute.after": async (input, output) => {
// input: { tool, sessionID, callID, args }
// output: { title, output, metadata }
},
"tool.definition": async (input, output) => {
// input: { toolID }
// output: { description, parameters }
},
Permission Hook
Intercept permission requests:
"permission.ask": async (input, output) => {
// input: Permission object
// output: { status: "ask" | "deny" | "allow" }
},
Shell Hook
Inject environment variables into shell execution:
"shell.env": async (input, output) => {
// input: { cwd, sessionID?, callID? }
// output: { env: Record<string, string> }
},
Custom Tools
Register tools the LLM can call:
import { type Plugin, tool } from "@toothfairyai/tfcode-plugin";
export const CustomToolsPlugin: Plugin = async (ctx) => {
return {
tool: {
mytool: tool({
description: "This is a custom tool",
args: {
foo: tool.schema.string(),
},
async execute(args, context) {
// context: { sessionID, messageID, agent, directory, worktree, abort, metadata, ask }
return `Hello ${args.foo}`;
},
}),
},
};
};
args is a Zod raw shape (pass the shape directly, not z.object(...)). Access Zod via tool.schema (e.g. tool.schema.string(), tool.schema.number()).
ToolContext fields:
| Field | Description |
|---|---|
sessionID | Current session ID |
messageID | Current message ID |
agent | Agent name executing the tool |
directory | Project directory |
worktree | Git worktree root |
abort | AbortSignal for cancellation |
metadata | Call metadata({ title?, metadata? }) to update info |
ask | Call ask({ permission, patterns, always, metadata }) to request user permission |
Plugin tools override built-in tools with the same name.
Experimental Hooks
"experimental.chat.messages.transform": async (input, output) => {
// output: { messages: { info, parts }[] }
},
"experimental.chat.system.transform": async (input, output) => {
// input: { sessionID?, model }
// output: { system: string[] }
},
"experimental.session.compacting": async (input, output) => {
// input: { sessionID }
// output: { context: string[], prompt? }
},
"experimental.text.complete": async (input, output) => {
// input: { sessionID, messageID, partID }
// output: { text }
},
For compaction: output.context appends extra context. Setting output.prompt replaces the entire compaction prompt (and output.context is ignored).
Auth Hook
Register OAuth or API key authentication providers. See the built-in auth plugins for examples.
Examples
.env Protection
.tfcode/plugins/env-protection.js
export const EnvProtection = async () => {
return {
"tool.execute.before": async (input, output) => {
if (input.tool === "read" && output.args.filePath.includes(".env")) {
throw new Error("Do not read .env files");
}
},
};
};
Inject Environment Variables
.tfcode/plugins/inject-env.js
export const InjectEnvPlugin = async () => {
return {
"shell.env": async (input, output) => {
output.env.MY_API_KEY = "secret";
output.env.PROJECT_ROOT = input.cwd;
},
};
};
Logging
export const MyPlugin = async ({ client }) => {
await client.app.log({
body: {
service: "my-plugin",
level: "info",
message: "Plugin initialized",
},
});
};
Built-in Plugins
TF Code includes two categories of built-in plugins: internal (always loaded, cannot be disabled) and builtin (opt-in, must be enabled).
Enabling built-in plugins
Built-in capabilities are gated by perks — the single gate for every capability. Enable them under perks in your config:
{
"perks": {
"notifications": true,
"email": true,
"goal": true,
"vibeguard": true,
"scheduler": true,
"gateway": true,
"loops": true
}
}
You can also toggle them interactively:
- Ask the agent ("what plugins should I turn on?") — it uses the
plugin_recommendtool to list, recommend, and switch them, asking for confirmation before anything changes /pluginsin the TUI (see Commands)- The server endpoints:
GET /plugin/builtin,POST /plugin/{name}/enable,POST /plugin/{name}/disable
All writes persist to perks.* in tfcode.json (the project file when one exists, else the global config) and apply to new tool surfaces. Toggling is blocked in unattended/scheduled runs — a human must confirm capability changes.
The legacy builtin config ("builtin": { "scheduler": { "enabled": true } }) still works as a read-only fallback — perks.<name> always wins when set. Prefer perks for new configs.
The plugin_recommend tool
Agents manage capabilities through the plugin_recommend tool instead of pointing you at settings files:
| Action | Description |
|---|---|
list | Show every perk/plugin with its on/off state (●/○) and description |
recommend | Suggest capabilities worth turning on that are currently off |
enable | Turn capabilities on (asks for confirmation first) |
disable | Turn capabilities off (asks for confirmation first) |
Internal Plugins
| Plugin | Description |
|---|---|
| ToothFairyAI | Handles ToothFairyAI provider authentication |
| Copilot | Handles GitHub Copilot OAuth authentication |
| Codex | Handles OpenAI Codex OAuth authentication |
These manage authentication flows and cannot be disabled.
Builtin Plugins
Builtin plugins are disabled by default. Enable them via perks (see above) — the legacy builtin block still works as a read-only fallback:
{
"builtin": {
"notifications": { "enabled": true },
"email": { "enabled": true },
"goal": { "enabled": true },
"vibeguard": { "enabled": true },
"scheduler": { "enabled": true },
"gateway": { "enabled": true },
"loops": { "enabled": true }
}
}
The server also exposes endpoints: GET /builtin, POST /builtin/:name/enable, POST /builtin/:name/disable.
Notifications
Desktop notifications and sound alerts for session events.
| Event | Notification | Sound |
|---|---|---|
session.idle | "Generation completed" | Yes |
permission.asked | "Permission request" | Yes |
Platform support: macOS (osascript + afplay), Linux (notify-send, no sound). No configuration needed.
Email
Send premium branded HTML email notifications through the ToothFairyAI SES pipeline. Registers the send_email tool for all agents.
Tool: send_email
| Parameter | Required | Description |
|---|---|---|
to | yes | Recipient email address |
subject | yes | Email subject line |
body | yes | Email content in Markdown — rendered to a branded HTML email |
firstname | no | Recipient first name, shown in the greeting |
cta_url | no | URL for a call-to-action button |
cta_label | no | CTA button label (default "Open TF Code") |
alias | no | Sender display name (default "ToothFairyAI") |
sender | no | Sender email address (defaults to the platform default) |
file_s3_keys | no | S3 keys of files to attach (requires s3_bucket) |
s3_bucket | no | S3 bucket holding file_s3_keys |
The body is rendered to a premium branded HTML layout, queued through the SES pipeline (SQS emailsQueue), and the call returns immediately with the message id. The agent asks the user for the recipient before sending unless it is already provided.
Goal
Session-scoped objective tracking with auto-continue. Set a goal condition and the agent keeps working toward it automatically until complete, blocked, or budget exhausted.
Usage:
/goal Fix all failing tests in the test suite
/goal status
/goal history
/goal pause
/goal resume
/goal clear
How it works:
- Sets a goal condition and injects
<goal_objective>into the system prompt - When the session goes idle and a goal is active, auto-continue sends a new prompt to keep the agent working
- The agent marks
[goal:complete]or[goal:blocked]when done or stuck - Budget tracking warns near limits and triggers a wrap-up handoff at 80% of token budget
- Stalled detection pauses auto-continue if output tokens fall below threshold for consecutive turns
Configuration (set at goal-set time):
| Flag | Default | Description |
|---|---|---|
--max-turns | 10 | Max auto-continue prompts |
--max-minutes | 15 | Max wall-clock duration |
--max-tokens | 200000 | Max context tokens |
--cooldown-ms | 1500 | Min delay between auto-continues |
--no-progress-threshold | 50 | Output token threshold for "no progress" |
--no-progress-turns | 2 | Consecutive low-progress turns before pause |
State is persisted to ~/.tfcode/goal-plugin/state.json and recovered on restart (auto-paused).
Scheduler
Schedule recurring AI agent jobs and routines using OS schedulers with cron expressions. A job runs the same prompt every tick; a routine runs a wake-and-decide gate that skips the model call entirely when there is nothing to do, so idle ticks cost ~nothing. The plugin is disabled by default — both jobs and routines are gated behind this single plugin, so it must be enabled before any scheduling works.
Tools registered:
| Tool | Description |
|---|---|
schedule_job | Create a job (fixed prompt every tick) with cron, prompt, timeout. Optional once (one-shot, self-deletes after a successful run) and kind (job/routine) |
create_routine | Create a routine (wake-and-decide); confirms a checklist first |
test_routine | Test-run a routine, bypassing the decision gate |
list_jobs | List all jobs and routines for the current project scope |
get_job | Get details of a specific job or routine |
update_job | Update cron/prompt/timeout/enabled/policy and routine-specific fields |
delete_job | Delete a job or routine and remove it from the OS scheduler |
run_job | Run a job immediately (fire-and-forget) |
job_logs | Read last N lines of a job's log |
Example:
tfcode run "Schedule a job that runs 'npm test' every weekday at 9am"
Routines wake through a headless tfcode scheduler tick <id> command so the decision gate, run tracking, and ROUTINE_OK handling all apply. See Scheduling for the full routine model.
Platform support:
- macOS — Uses
launchdwith.plistin~/Library/LaunchAgents/and a Perl supervisor script for file locking and auto-timeout - Linux — Uses
systemdwith.service/.timerin~/.config/systemd/user/
Jobs are stored per project scope (FNV-1 hash of working directory) at {state_dir}/scheduler/scopes/{scopeId}/{jobId}.json. Cron expressions require at least 5 fields. Default timeout: 300 seconds.
Permission prompts are automatically denied in scheduled runs so jobs and routines can execute unattended.
VibeGuard
Redacts secrets and PII into HMAC-derived placeholders before LLM calls, then restores originals locally for tool execution. Prevents sensitive data from being sent to remote LLMs.
How it works:
- Redact — Outgoing messages are scanned for keyword/regex matches and replaced with deterministic HMAC-SHA256 placeholders like
__VG_EMAIL_a1b2c3d4e5f6__ - Restore — Tool arguments and LLM output are restored to real values before execution/display
- Session-scoped — Each chat session gets its own mapping, TTL-expired (1 hour), capped at 100,000 entries with LRU eviction
Built-in recognizers:
| Name | Pattern | Category |
|---|---|---|
email | Email addresses | |
china_phone | Chinese mobile numbers | CHINA_PHONE |
china_id | Chinese national IDs | CHINA_ID |
uuid | UUID format | UUID |
ipv4 | IPv4 addresses | IPV4 |
mac | MAC addresses | MAC |
Configuration — Create vibeguard.config.json (searched in project dir, .opencode/, ~/.config/opencode/, ~/.config/tfcode/, or set OPENCODE_VIBEGUARD_CONFIG env var):
{
"enabled": true,
"debug": false,
"placeholder_prefix": "__VG_",
"session": {
"ttl": "1h",
"max_mappings": 100000
},
"patterns": {
"keywords": [{ "value": "secret-key-123", "category": "SECRET" }],
"regex": [{ "pattern": "\\d{16}", "flags": "", "category": "CC" }],
"builtin": ["email", "china_phone", "china_id", "uuid", "ipv4", "mac"],
"exclude": ["safe-value"]
}
}
Disabled by default — only activates when enabled: true is set in the config file. Use OPENCODE_VIBEGUARD_DEBUG=true for debug logging.
Gateway
Webhook receiver that connects TF Code to external services (GitHub, Gitea, GitLab, AWS Batch, etc.). Exposes an HTTP server on a configurable port that accepts webhook POSTs, validates HMAC signatures, filters events, renders prompt templates from payloads, and dispatches agent runs.
Enable:
{
"builtin": { "gateway": { "enabled": true } },
"gateway": {
"port": 8644,
"secret": "global-hmac-secret",
"routes": { ... }
}
}
See the Gateway configuration guide for full documentation including security, routes, prompt templates, and examples.
Loops
Structured execution-path workflows — named steps the agent traverses with loop_next/loop_done tools. The agent decides when to loop back, advance, or stop. Supports multi-agent transitions (swap agent per step), per-step compaction, reviewer, model_swap, and skills.
Enable:
{
"perks": { "loops": true },
"loops": {
"definitions": {
"fix-bug": {
"steps": [
{
"name": "read",
"agent": "general",
"prompt": "Read and understand the task."
},
{ "name": "build", "prompt": "Implement the change." },
{
"name": "test",
"prompt": "Run the tests. If failing, call loop_next({ step: 'build' })."
},
{ "name": "review", "review": true, "prompt": "Review the change." },
{
"name": "report",
"agent": "TF Engineer",
"prompt": "Report the outcome."
}
]
}
}
}
}
See the Loops configuration guide for full documentation.
Agent Plugins
Beyond JS plugins, the agent can author, install, and iterate on its own plugins — like it already does with skills — gated behind a single perk. Agent plugins are declarative manifests interpreted natively by the server: no JS, no sandbox, no runtime penalty.
Enabling
{
"perks": { "plugins": true }
}
While on, the agent gets the plugin_manage tool and all hook surfaces. While off: no tool, no hooks, and the HTTP routes return 403.
Manifest format
One directory per plugin: .tfcode/plugin/<name>/plugin.json (plus optional scripts/ and reference/ resources — shell handlers run with cwd = the plugin dir).
.tfcode/plugin/commit-guard/plugin.json
{
"name": "commit-guard",
"description": "Guards deploys and adds a deploy tool",
"enabled": true,
"tools": {
"deploy": {
"description": "Deploy the current branch to staging",
"parameters": { "type": "object", "properties": { "env": { "type": "string" } }, "required": ["env"] },
"handler": { "type": "shell", "command": "scripts/deploy.sh {{env}}", "timeoutMs": 60000 }
}
},
"hooks": {
"tool.execute.before": [
{
"match": { "tool": "bash", "argsContains": "rm -rf /" },
"action": { "type": "block", "message": "destructive command blocked by commit-guard" }
}
],
"tool.execute.after": [
{ "match": { "tool": "grep" }, "action": { "type": "append", "text": "\n(checked)" } }
],
"event": [
{ "match": { "type": "session.idle" }, "action": { "type": "shell", "command": "scripts/notify.sh" } }
],
"chat.system.transform": [
{ "action": { "type": "append", "text": "\nExtra guardrail context." } }
],
"shell.env": [
{ "match": { "cwdPrefix": "rust/" }, "action": { "type": "set", "env": { "CARGO_TERM_COLOR": "0" } } }
]
}
}
Tools
| Field | Meaning |
|---|---|
description | What the model sees |
parameters | JSON schema (must be type: "object"); may not shadow a built-in tool id |
handler | shell (command, {{arg}} interpolation, cwd = plugin dir, timeout, output = stdout), http (url/method/body/headers, output = response body), response (literal text, interpolated) |
Hooks
Valid hook names: tool.execute.before, tool.execute.after, event, chat.system.transform, shell.env, experimental.chat.messages.transform.
Match fields (all AND, all optional): tool, type (event name), role, argsContains (deep substring across the args), cwdPrefix. An empty match = always.
Action types:
| Type | Behavior |
|---|---|
shell | Run a command (interpolated, timeout'd) |
http | Call an endpoint |
response | Literal text |
block | Deny the tool call with a message (tool.execute.before only) |
transform | Find/replace at a dotted JSON path (command, parts.0.text, …) |
append | Append text to the tool output / system prompt |
set | Set env vars (shell.env only; SHELL/HOME/PATH are protected) |
The plugin_manage tool
Actions: list, search, read, create, update, delete, enable, disable, validate, test, invalidate. The test action dry-runs a hook against sample facts and reports which rules matched and what they would do — the agent's debugging loop.
Semantics
- Reload: manifests are mtime-cached; every write invalidates the cache. Parsing is microseconds — no watcher.
- Validation: name charset
[a-z0-9-](max 64), name must match the directory,parametersmust be an object schema, unknown hooks/fields are rejected,block/setare only valid in their hooks, built-in tool ids cannot be shadowed. - Failure isolation: a bad manifest is skipped (and reported by
plugin_manage listas invalid); a failing hook action surfaces in logs but never aborts the prompt loop. - Ordering: agent-plugin before-hooks run after the built-in gates.
HTTP API
| Route | Method | Meaning |
|---|---|---|
/plugin/agent | GET | List manifests (perk 403s when off) |
/plugin/agent/{name} | GET / PUT / DELETE | Read / create-or-replace (validated) / delete |
/plugin/agent/{name}/test | POST | Dry-run one hook against sample facts |