Skip to main content

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 in config).

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 in your config:

{
"builtin": {
"notifications": { "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.


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:

{
"builtin": { "loops": { "enabled": 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.