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 in config).
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 in your config:
{
"builtin": {
"notifications": { "enabled": true },
"goal": { "enabled": true },
"vibeguard": { "enabled": true },
"scheduler": { "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.
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 using OS schedulers with cron expressions.
Tools registered:
| Tool | Description |
|---|---|
schedule_job | Create a job with cron, prompt, timeout |
list_jobs | List all jobs for current project scope |
get_job | Get details of a specific job |
update_job | Update cron/prompt/timeout/enabled |
delete_job | Delete a job and remove from 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"
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 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.