Skip to main content
Version: v3 (beta)

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​

  1. Internal auth plugins (always loaded)
  2. Builtin opt-in plugins (from builtin config)
  3. Remote well-known config plugins
  4. Global config (~/.config/tfcode/tfcode.json)
  5. Custom config (OPENCODE_CONFIG env var)
  6. Project config (tfcode.json)
  7. .tfcode directory plugins (home + project)
  8. Inline config (OPENCODE_CONFIG_CONTENT env var)
  9. 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:

FieldDescription
projectCurrent project information (id, worktree, etc)
directoryCurrent working directory
worktreeGit worktree path
clientTF Code SDK client for interacting with the AI
$Bun's shell API for executing commands
serverUrlURL 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:

FieldDescription
sessionIDCurrent session ID
messageIDCurrent message ID
agentAgent name executing the tool
directoryProject directory
worktreeGit worktree root
abortAbortSignal for cancellation
metadataCall metadata({ title?, metadata? }) to update info
askCall 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_recommend tool to list, recommend, and switch them, asking for confirmation before anything changes
  • /plugins in 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.

note

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:

ActionDescription
listShow every perk/plugin with its on/off state (●/○) and description
recommendSuggest capabilities worth turning on that are currently off
enableTurn capabilities on (asks for confirmation first)
disableTurn capabilities off (asks for confirmation first)

Internal Plugins​

PluginDescription
ToothFairyAIHandles ToothFairyAI provider authentication
CopilotHandles GitHub Copilot OAuth authentication
CodexHandles 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.

EventNotificationSound
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

ParameterRequiredDescription
toyesRecipient email address
subjectyesEmail subject line
bodyyesEmail content in Markdown — rendered to a branded HTML email
firstnamenoRecipient first name, shown in the greeting
cta_urlnoURL for a call-to-action button
cta_labelnoCTA button label (default "Open TF Code")
aliasnoSender display name (default "ToothFairyAI")
sendernoSender email address (defaults to the platform default)
file_s3_keysnoS3 keys of files to attach (requires s3_bucket)
s3_bucketnoS3 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:

  1. Sets a goal condition and injects <goal_objective> into the system prompt
  2. When the session goes idle and a goal is active, auto-continue sends a new prompt to keep the agent working
  3. The agent marks [goal:complete] or [goal:blocked] when done or stuck
  4. Budget tracking warns near limits and triggers a wrap-up handoff at 80% of token budget
  5. Stalled detection pauses auto-continue if output tokens fall below threshold for consecutive turns

Configuration (set at goal-set time):

FlagDefaultDescription
--max-turns10Max auto-continue prompts
--max-minutes15Max wall-clock duration
--max-tokens200000Max context tokens
--cooldown-ms1500Min delay between auto-continues
--no-progress-threshold50Output token threshold for "no progress"
--no-progress-turns2Consecutive 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:

ToolDescription
schedule_jobCreate 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_routineCreate a routine (wake-and-decide); confirms a checklist first
test_routineTest-run a routine, bypassing the decision gate
list_jobsList all jobs and routines for the current project scope
get_jobGet details of a specific job or routine
update_jobUpdate cron/prompt/timeout/enabled/policy and routine-specific fields
delete_jobDelete a job or routine and remove it from the OS scheduler
run_jobRun a job immediately (fire-and-forget)
job_logsRead 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 launchd with .plist in ~/Library/LaunchAgents/ and a Perl supervisor script for file locking and auto-timeout
  • Linux — Uses systemd with .service/.timer in ~/.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:

  1. Redact — Outgoing messages are scanned for keyword/regex matches and replaced with deterministic HMAC-SHA256 placeholders like __VG_EMAIL_a1b2c3d4e5f6__
  2. Restore — Tool arguments and LLM output are restored to real values before execution/display
  3. Session-scoped — Each chat session gets its own mapping, TTL-expired (1 hour), capped at 100,000 entries with LRU eviction

Built-in recognizers:

NamePatternCategory
emailEmail addressesEMAIL
china_phoneChinese mobile numbersCHINA_PHONE
china_idChinese national IDsCHINA_ID
uuidUUID formatUUID
ipv4IPv4 addressesIPV4
macMAC addressesMAC

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​

FieldMeaning
descriptionWhat the model sees
parametersJSON schema (must be type: "object"); may not shadow a built-in tool id
handlershell (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:

TypeBehavior
shellRun a command (interpolated, timeout'd)
httpCall an endpoint
responseLiteral text
blockDeny the tool call with a message (tool.execute.before only)
transformFind/replace at a dotted JSON path (command, parts.0.text, …)
appendAppend text to the tool output / system prompt
setSet 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, parameters must be an object schema, unknown hooks/fields are rejected, block/set are only valid in their hooks, built-in tool ids cannot be shadowed.
  • Failure isolation: a bad manifest is skipped (and reported by plugin_manage list as 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​

RouteMethodMeaning
/plugin/agentGETList manifests (perk 403s when off)
/plugin/agent/{name}GET / PUT / DELETERead / create-or-replace (validated) / delete
/plugin/agent/{name}/testPOSTDry-run one hook against sample facts