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 }
}
}

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 using OS schedulers with cron expressions.

Tools registered:

ToolDescription
schedule_jobCreate a job with cron, prompt, timeout
list_jobsList all jobs for current project scope
get_jobGet details of a specific job
update_jobUpdate cron/prompt/timeout/enabled
delete_jobDelete a job and remove from 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"

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 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.