Plugins
Plugins are TypeScript or JavaScript modules that add tools, commands, and event-driven behavior to Amp. A plugin can be a single file or a directory with supporting files. Plugins run code in your environment, so only load plugins you trust.
Plugins can:
- Handle events —
amp.on(...)for tool calls, tool results, and agent lifecycle events - Add tools —
amp.registerTool(...)for custom tools the agent can call - Bundle skills —
await amp.registerSkill(...)to register skill directories - Add commands —
amp.registerCommand(...)for command palette actions - Show UI —
ctx.ui.notify(...),ctx.ui.confirm(...),ctx.ui.input(...), andctx.ui.select(...) - Classify with AI —
amp.ai.ask(...)for thread-scoped yes/no decisions
Plugin Locations and Precedence
Amp loads plugins from these sources:
- Personal plugins. Manage them in Personal Settings. They apply everywhere you use Amp.
- Workspace plugins. Workspace admins manage them in Workspace Settings. They apply to everyone in the workspace.
- Project plugins. Put files or directories in
.amp/plugins/at the project root. They apply when you run Amp in that project. - System plugins. If
XDG_CONFIG_HOMEis set, put files or directories in$XDG_CONFIG_HOME/amp/plugins/. Otherwise, use~/.config/amp/plugins/on macOS and Linux or%USERPROFILE%\.config\amp\plugins\on Windows. They apply to your projects on that machine.
When plugins have the same name, the precedence order is project, system, personal, then workspace.
Adding Plugins
For most plugins, import a shared plugin into your personal plugins. Personal plugins are available everywhere you use Amp. Paste the plugin’s share URL into a thread and ask Amp to import it:
Workspace admins can import a shared plugin into workspace plugins to make it available to everyone in the workspace. See Global Plugin Repositories below.
For a plugin tied to one project, put its file or directory in .amp/plugins/.
To install a single-file plugin from a URL for all projects on your machine, run:
amp plugins add <url> Add --target workspace to install it in the current project’s .amp/plugins/ directory instead.
Global Plugin Repositories
Personal and workspace plugins live in global plugin repositories. Your personal plugin repository applies only to you. Workspace admins manage the workspace repository, whose plugins apply to everyone in the workspace. The simplest way to manage either is to ask Amp in a thread.
Amp finds the right repository, makes the requested change, commits it, and asks before pushing. A push publishes the change. Amp can then reload the plugin in the current thread without restarting.
To share a personal plugin, open Plugins in Personal Settings, select the plugin, choose Share, make it available to the workspace, and copy its URL. Send the URL directly or paste it into Slack, where it unfurls with details about the plugin. Teammates can paste the URL into a thread or ask Amp, “Has anyone got a tmux plugin?”
For direct shell access, amp plugins repositories lists the repositories and clone commands. amp clone user-plugins and amp clone workspace-plugins clone them. The amp plugins import and amp plugins update commands manage shared imports. Repository owners can require signed commits
in the repository’s Advanced settings.
Writing Plugins
A single-file plugin is a .ts or .js file directly inside a plugin location. A directory plugin
uses <plugin-name>/index.ts or <plugin-name>/index.js. If both entry files exist, Amp uses index.ts. The entry file can import supporting files with relative paths.
deploy-status/
├── index.ts
├── client.ts
└── prompts/ Every plugin entry file exports a default function. Amp passes a PluginAPI object to that
function.
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
amp.logger.log('Plugin initialized')
} Code in the exported function runs when the plugin loads. Use session.start only for work that should run when Amp starts a specific thread session.
Amp also has a built-in skill for writing plugins, so you can just ask it to write a plugin for you.
Bundle a Skill
A directory plugin can include a standard skill and its resources. Amp does not scan the skills/ directory automatically, so the plugin must register each skill when it loads.
deploy-status/
├── index.ts
└── skills/
└── deploy-guide/
├── SKILL.md
└── reference/
└── checklist.md import type { PluginAPI } from '@ampcode/plugin'
export default async function (amp: PluginAPI) {
await amp.registerSkill({ path: 'skills/deploy-guide' })
} The path is relative to the plugin directory and must name a directory that contains SKILL.md.
The skill’s frontmatter name must match its directory name. In this example, Amp lists the skill
as deploy-status:deploy-guide. Single-file plugins cannot register skills.
A bundled skill can list the plugin’s own tools in its builtin-tools frontmatter field. Those
tools stay hidden from the model until the skill is loaded, keeping large tool sets from occupying
context in unrelated threads. See the Plugin API reference for details.
Reloading Plugins
After changing a plugin, ask Amp to reload it. You can also ask Amp to list the plugins available in
the thread. For direct control, open the command palette with Ctrl+O and run plugins: reload or plugins: list. Run amp plugins list in a shell to see loaded plugins, their
sources, registered events, commands, and tools.
Plugin UI is mirrored across TUI and Web surfaces:
Plugin activation settings apply to both interactive amp sessions and amp --execute runs.
Event Examples
Plugin events follow a thread session’s agent lifecycle. session.start is emitted for the thread session; each user turn then starts, may run tools, and eventually ends.
╭───────────────╮ ╭─────────────╮ ╭───────────╮ ╭─────────────╮ ╭───────────╮
│ session.start │───▶│ agent.start │───▶│ tool.call │───▶│ tool.result │───▶│ agent.end │
╰───────────────╯ ╰─────────────╯ ╰───────────╯ ╰─────────────╯ ╰───────────╯
▲ │
╰──── per tool ───╯ session.start: Run Setup When a Thread Starts
session.start fires when Amp starts a thread session, such as when the user sends the first message in a new thread or opens/switches to an existing thread. Put plugin-load initialization directly in the exported function body. Multiple threads can be started and continue to run at the same time in the same Amp CLI. There is no session.end event.
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
amp.on('session.start', async (event, ctx) => {
await ctx.ui.notify(`Example session.start for ${event.thread.id}.`)
})
} tool.call: Approve or Reject a Tool Call
tool.call fires before a tool runs. Return allow to run the tool, reject-and-continue to block it and let the agent continue, modify to change the input, or synthesize to provide a result without running the tool.
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
amp.on('tool.call', async (event, ctx) => {
const confirmed = await ctx.ui.confirm({
title: `Allow ${event.tool}?`,
message: `Amp wants to call ${event.tool}.`,
confirmButtonText: 'Allow',
})
if (confirmed) {
return { action: 'allow' }
}
return {
action: 'reject-and-continue',
message: `The user rejected ${event.tool}.`,
}
})
} 
tool.result: Observe or Modify a Tool Result
tool.result fires after a tool finishes and before the result is sent back to the model. Return nothing to keep the original result, or return a replacement status/output.
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
amp.on('tool.result', async (event, ctx) => {
if (event.status === 'error') {
await ctx.ui.notify(`Tool failed: ${event.tool}`)
}
})
} agent.start: Notify When a Turn Starts
agent.start fires when the user submits a prompt. It is useful for reacting to new turns before
the agent starts working.
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
amp.on('agent.start', async (_event, ctx) => {
await ctx.ui.notify('Amp is starting a new turn.')
})
} agent.end: Continue After a Turn Ends
agent.end fires when the agent finishes a turn. Return continue to append a follow-up user message and start another turn. Always include a marker or other guard when returning continue so your plugin does not loop forever. As a backstop, Amp stops chaining plugin continue messages after five in a row; the next message from the user starts a fresh chain. A plugin that drives long-running work can raise this limit by setting maxContinuations on the continue result, as the /goal example does.
import type { PluginAPI } from '@ampcode/plugin'
const marker = '[plugin:tests-requested]'
export default function (amp: PluginAPI) {
amp.on('agent.end', (event) => {
if (!event.message.toLowerCase().includes('verify')) {
return
}
if (event.message.includes(marker)) {
return
}
return {
action: 'continue',
userMessage: `${marker} Before finishing, run the most relevant tests for your changes.`,
}
})
} Command, Tool, and UI Examples
Add a Command
Commands appear in Amp’s command palette.
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
amp.registerCommand(
'open-plugin-docs',
{
title: 'Open plugin docs',
category: 'docs',
description: 'Open the Amp Plugin API reference page.',
},
async (ctx) => {
await ctx.system.open('https://ampcode.com/docs/plugin-api')
},
)
} 
Changing Command Availability
amp.registerCommand(...) accepts an optional availability and returns a subscription whose setAvailability(...) method updates how the command appears in the palette:
{ type: 'enabled' }— shown and selectable (the default).{ type: 'disabled', reason: '...' }— shown but not selectable;reasonis displayed alongside the command.{ type: 'hidden' }— not shown at all.
This plugin adds two commands that toggle Amp’s built-in notifications.enabled setting and keeps the palette showing only the relevant one.
import type { CommandSubscription, PluginAPI } from '@ampcode/plugin'
export default async function (amp: PluginAPI) {
const isEnabled = (config: Record<string, unknown>) => config['notifications.enabled'] !== false
let mute: CommandSubscription | undefined
let unmute: CommandSubscription | undefined
const refresh = (enabled: boolean) => {
mute?.setAvailability(enabled ? { type: 'enabled' } : { type: 'hidden' })
unmute?.setAvailability(enabled ? { type: 'hidden' } : { type: 'enabled' })
}
const enabled = isEnabled(await amp.configuration.get())
mute = amp.registerCommand(
'mute-notifications',
{
title: 'Mute notifications',
category: 'notifications',
availability: enabled ? { type: 'enabled' } : { type: 'hidden' },
},
async (ctx) => {
await amp.configuration.update({ 'notifications.enabled': false }, 'global')
await ctx.ui.notify('Notifications muted.')
},
)
unmute = amp.registerCommand(
'unmute-notifications',
{
title: 'Unmute notifications',
category: 'notifications',
availability: enabled ? { type: 'hidden' } : { type: 'enabled' },
},
async (ctx) => {
await amp.configuration.update({ 'notifications.enabled': true }, 'global')
await ctx.ui.notify('Notifications unmuted.')
},
)
amp.configuration.subscribe((config) => {
refresh(isEnabled(config))
})
} Register a Tool
Tools registered by plugins are available to the model alongside Amp’s built-in tools.
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
amp.registerTool({
name: 'project_status',
description: 'Show the current git status for this repository.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
async execute() {
const result = await amp.$`git status --short`
return result.stdout || 'No changes.'
},
})
} Tools can also return images by returning an array of content blocks that mixes text and image
blocks. For large images such as screenshots, upload the bytes with amp.attachments.upload(...) and return a URL-backed image block so thread state stores a URL instead of the base64 payload.
Image uploads are validated against Amp’s inference image limits (at most 4.9 MB decoded and
8000px per dimension). Fall back to an inline base64 block if the upload fails.
async execute() {
const png: Uint8Array = await captureScreenshot()
try {
const { url } = await amp.attachments.upload({ data: png, mimeType: 'image/png' })
return [{ type: 'image', mimeType: 'image/png', url }]
} catch {
return [{ type: 'image', mimeType: 'image/png', data: Buffer.from(png).toString('base64') }]
}
} Ask the User for Input
Plugins can show notifications, confirmation dialogs, text inputs, and selection dialogs.
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
amp.registerCommand(
'add-note-to-thread',
{
title: 'Add note to thread',
category: 'notes',
description: 'Prompt for a note and append it to the current thread.',
},
async (ctx) => {
const note = await ctx.ui.input({
title: 'Thread note',
helpText: 'What should Amp remember in this thread?',
submitButtonText: 'Add note',
})
if (!note) {
return
}
if (!ctx.thread) {
await ctx.ui.notify(
'No active thread. Send any message to create one, then re-run this command.',
)
return
}
await ctx.thread.append([{ type: 'user-message', content: note }])
},
)
} Selection dialogs can optionally append a final inline text field with Other as its placeholder.
Set allowOther: true and enter a custom value directly in the list. Surrounding whitespace is
trimmed, and empty values are not submitted. The promise resolves to either a listed option or the
entered text, and still resolves to undefined if the user cancels.
Confirmation dialogs can include required editable text fields. Each field starts with the supplied
value. Confirming returns a record keyed by field name, while cancelling returns undefined.
Without fields, confirm keeps returning a boolean.
const values = await ctx.ui.confirm({
title: 'Review content',
message: 'Check these values before continuing.',
confirmButtonText: 'Continue',
fields: [
{ name: 'title', label: 'Title', value: 'Weekly update' },
{ name: 'content', label: 'Content', value: 'Hello team,', multiline: true },
],
})
if (!values) return const environment = await ctx.ui.select({
title: 'Choose an environment',
message: 'Select a known environment or enter another one.',
allowOther: true,
options: ['Development', 'Staging', 'Production'],
}) Use Thread-Scoped AI
Use amp.ai.ask(...) when a plugin needs a small yes/no classification decision with reasoning.
This helper runs through the current thread; pass { threadID } when calling it outside a
thread-bound handler. AI helper calls default to no reasoning; pass { reasoningEffort } to opt in
for models that support it.
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
amp.on('agent.start', async (event, ctx) => {
const answer = await amp.ai.ask(
`Is this request asking to change production infrastructure? ${event.message}`,
)
if (answer.result === 'yes') {
await ctx.ui.notify(`This looks production-related: ${answer.reason}`)
}
})
} Define a Custom Agent Mode
Use amp.createAgent(...) and amp.registerAgentMode(...) to add a mode that appears alongside
Amp’s built-in modes in supported clients. The mode shows up in the mode picker without taking a
slot on your dial. See Use Modes That Are Not on Your Dial.
Write the mode description to state both what the mode does and when to use it.
Run amp plugins show-agent-options to list the model IDs and built-in tools available to custom
plugin agents.
External plugins must include one matching // @amp-agent-mode ... metadata comment with the mode key and label for each registered mode. Clients use those comments for static discovery and show
a warning toast when a runtime registration does not match its directive. Multiple mode comments in
one plugin file are supported.
// @amp-agent-mode {"key":"architect","label":"architect"}
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
const architect = amp.createAgent({
name: 'architect',
model: 'openai/gpt-5.5',
instructions: [
'You are an architecture-focused Amp mode.',
'Before editing code, map the current design, name the tradeoffs,',
'and prefer small changes that preserve clear module boundaries.',
].join(' '),
tools: 'all',
reasoningEffort: 'high',
display: { label: 'architect', color: '#7c3aed' },
})
amp.registerAgentMode({
key: 'architect',
description:
'Plans and implements changes with extra architecture scrutiny. Use for architecture-sensitive work.',
agent: architect.definition,
})
} The optional name is part of the agent’s identity. Amp includes You are <name>, a custom agent running in Amp. in the base system prompt. Omit name to avoid
adding a named identity. The label only controls how the mode appears in the UI.
Set extends to a built-in mode (low, medium, high, or ultra) to build on that mode instead
of starting from scratch. The agent uses the built-in mode’s full system prompt with your instructions appended as an extra section, defaults to the mode’s tool list, and inherits the
mode’s model and reasoning effort when you omit model. Use tools: { add: [...] } to add tools on
top of the inherited list, or tools: { exclude: [...] } to remove some.
const carefulReviewer = amp.createAgent({
extends: 'high',
instructions: 'Review changes with extra care. Never edit files.',
tools: { add: ['mcp__linear__*'], exclude: ['edit_file'] },
}) The optional display on createAgent travels with the agent definition, so threads created
from it — including by other plugins via a thread.agent() handle — show its label and color. registerAgentMode defaults its label and color from the agent’s display; pass them
explicitly to override.
Features set on createAgent are inherited by every thread created from that agent. Features passed
to agent.createThread(...) are added to that list. If a registered mode requires features, include
the same features list in its // @amp-agent-mode ... metadata so clients can discover the
requirements before the plugin starts.
Custom mode keys and labels must be unique (case-insensitive), non-empty, 24 characters or less, and must not conflict with built-in modes. Existing external plugins that register an agent mode without the directive continue to load and their mode remains available, but clients warn until the plugin adds matching metadata and is reloaded.
Plugin agents can create threads from interactive sessions, amp --execute, and amp --no-tui runners. Modes from a live runner can also appear in the mode picker on ampcode.com.
Define a Custom Subagent
Create an agent and expose it through a plugin tool when you want the main agent to delegate a
specific kind of work on demand. The parentThreadID option keeps the subagent run connected to the
thread that invoked the tool.
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
const researcher = amp.createAgent({
name: 'dependency-researcher',
model: 'openai/gpt-5.5',
instructions: [
'You are a focused dependency research subagent.',
'Research only the dependency and question named by the caller.',
'Return concise findings with links to primary sources.',
].join(' '),
tools: 'all',
reasoningEffort: 'medium',
})
amp.registerTool({
name: 'dependency_research_subagent',
description: 'Run a focused subagent for a dependency research request.',
inputSchema: {
type: 'object',
properties: {
request: {
type: 'string',
description: 'The dependency and question the subagent should research.',
},
},
required: ['request'],
},
async execute(input, ctx) {
const request = typeof input.request === 'string' ? input.request : ''
if (!request.trim()) {
return 'Missing research request.'
}
const result = await researcher.run(request, {
parentThreadID: ctx.thread.id,
timeoutMs: 10 * 60 * 1000,
})
return result.text
},
})
} Use a Built-in Agent
Use amp.getBuiltinAgent(mode) to get a handle for one of Amp’s built-in agent modes
('low', 'medium', 'high', or 'ultra') instead of defining a custom agent. The deprecated 'smart', 'deep', and 'rush' modes are still accepted but spawn threads in their replacement
mode ('rush' → 'low'; 'smart'/'deep' → 'medium').
Both custom and built-in agent handles support run(message, options?) for a one-shot run and createThread(options?) for a thread you can keep appending messages to. Pass parentThreadID to
connect the new thread to its parent. From a command handler without ctx.thread, use createThread({ show: true }) to create a thread and make it active in supported clients.
Pass executor: 'orb' to run(...) or createThread(...) to start the thread in an orb when
the current client supports orb-backed thread creation. To target a live Amp runner, pass executor: { type: 'runner', id }. For workspace Orb threads created with createThread(...),
pass multiplayerTTLSeconds to enable multiplayer for 5 minutes through 7 days.
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
const high = amp.getBuiltinAgent('high')
amp.registerCommand(
'start-deep-dive',
{ title: 'Start Deep Dive', description: 'Start a background high-mode thread' },
async (ctx) => {
const thread = await high.createThread({
executor: 'orb',
multiplayerTTLSeconds: 3 * 60 * 60,
})
await thread.appendUserMessage({
type: 'user-message',
content: 'Investigate flaky tests in the CI pipeline.',
})
await ctx.ui.notify(`Started background thread ${thread.id}`)
},
)
} Example Plugin: Permissions
This plugin asks the user before running potentially destructive git commands. It uses amp.ai.ask(...) to classify each git command and only prompts when the command looks risky.
Save this as .amp/plugins/no-destructive-git-operations.ts, then run plugins: reload.
import type { PluginAIAskResult, PluginAPI } from '@ampcode/plugin'
/**
* Plugin that prevents risky git operations by asking the user for confirmation.
* Uses amp.ai.ask() to classify git commands as risky and prompts the user accordingly.
*/
export default function (amp: PluginAPI) {
const safePatterns = [
/^\s*git\s+status\b/,
/^\s*git\s+log\b/,
/^\s*git\s+diff\b/,
/^\s*git\s+show\b/,
/^\s*git\s+branch\s*$/,
/^\s*git\s+branch\s+-[av]\b/,
/^\s*git\s+stash\s+list\b/,
/^\s*git\s+remote\s+-v\b/,
/^\s*git\s+fetch\b/,
/^\s*git\s+pull\b/,
/^\s*git\s+add\b/,
/^\s*git\s+commit\b/,
/^\s*git\s+push\b(?!.*(-f|--force))/,
]
amp.on('tool.call', async (event, ctx) => {
const shellCommand = amp.helpers.shellCommandFromToolCall(event)
if (!shellCommand?.command) {
return { action: 'allow' }
}
const command = shellCommand.command
if (!/^\s*git\s+/.test(command)) {
return { action: 'allow' }
}
if (safePatterns.some((pattern) => pattern.test(command))) {
return { action: 'allow' }
}
const aiResponse: PluginAIAskResult = await amp.ai.ask(
`Does this git command look like a potentially destructive operation that could lose work? Answer yes if it's a destructive operation like force push, branch deletion, reset, or checkout to detached HEAD. Command: ${command}`,
)
if (aiResponse.result === 'no') {
return { action: 'allow' }
}
const confirmed = await ctx.ui.confirm({
title: 'Potentially destructive git operation',
message: `${command}\n\nReason: ${aiResponse.reason}\n\nDo you want to proceed?`,
confirmButtonText: 'Allow',
})
if (confirmed) {
return { action: 'allow' }
}
return {
action: 'reject-and-continue',
message: `User cancelled potentially destructive git operation: ${command}`,
}
})
} Example Plugin: BTW
This plugin adds a BTW: Ask command for quick side questions. It asks for the question with ctx.ui.input(...), creates a separate low-mode thread with amp.getBuiltinAgent('low') and createThread(...), waits for the answer with waitForResponse(...), and shows it in a ctx.ui.confirm(...) dialog with a button that opens the new thread. Your current thread is not
interrupted, and the side thread is linked to it through parentThreadID.
Save this as .amp/plugins/btw.ts, then run plugins: reload. Open the command palette and run BTW: Ask.
ctx.system.open(...) opens a URL on the machine running the plugin, so it only works when ctx.system.executor.kind is 'local'. In an orb, the plugin shows the thread URL in a
notification instead.
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
const low = amp.getBuiltinAgent('low')
amp.registerCommand(
'btw.ask',
{
title: 'Ask',
category: 'BTW',
description: 'Ask a quick side question in a separate thread',
},
async (ctx) => {
const question = await ctx.ui.input({
title: 'BTW',
helpText: 'Ask a quick side question without interrupting this thread.',
submitButtonText: 'Send',
})
if (!question?.trim()) {
return
}
const btw = await low.createThread(ctx.thread ? { parentThreadID: ctx.thread.id } : {})
await btw.appendUserMessage({
type: 'user-message',
content: `Answer in a few sentences: ${question}`,
})
await ctx.ui.notify('BTW sent.')
const reply = await btw.waitForResponse({ timeoutMs: 2 * 60 * 1000 })
const answer = reply.content
.filter((block) => block.type === 'text')
.map((block) => block.text)
.join('\n')
.trim()
const open = await ctx.ui.confirm({
title: 'BTW Response',
message: answer || '(No response.)',
confirmButtonText: 'Open BTW Thread',
})
if (!open) {
return
}
// Orbs cannot open a browser on your machine, so show the URL instead.
const url = new URL(`/threads/${btw.id}`, ctx.system.ampURL).href
if (ctx.system.executor.kind === 'local') {
await ctx.system.open(url)
} else {
await ctx.ui.notify(`BTW thread: ${url}`)
}
},
)
} Example Plugin: Turn Stats
This plugin times each agent turn and counts tool calls. It listens to agent.start, tool.result, and agent.end, shows a live status item that ticks every second, sends a
notification when the turn ends, and adds a Turn Stats: Show Turn Details command that shows the
tool breakdown for the last turn. Clicking the status item runs that command through its command: URL.
Status items appear in the CLI status line. Other clients ignore them, so the notification and the command work everywhere while the live counter is CLI-only.
Plugins receive events for every thread the client hosts, including side threads such as the ones
the BTW plugin creates. The plugin compares event.thread.id with amp.activeThread.current so it
only tracks the thread you are looking at.
Save this as .amp/plugins/turn-stats.ts, then run plugins: reload.
import type { PluginAPI } from '@ampcode/plugin'
export default function (amp: PluginAPI) {
let startedAt = 0
let endedAt: number | null = null
const toolCounts = new Map<string, number>()
let ticker: ReturnType<typeof setInterval> | undefined
const elapsedSeconds = () => Math.round(((endedAt ?? Date.now()) - startedAt) / 1000)
const toolTotal = () => [...toolCounts.values()].reduce((sum, count) => sum + count, 0)
const summary = () => {
const total = toolTotal()
return `${elapsedSeconds()}s · ${total} tool ${total === 1 ? 'call' : 'calls'}`
}
// Plugins receive events for every thread this client hosts, including side threads.
// Only track the thread the user is looking at.
const isActiveThread = (event: { thread: { id: string } }) =>
event.thread.id === amp.activeThread.current?.id
// Status items are shown in the TUI's status line; other clients ignore them.
const status = amp.experimental?.createStatusItem()
const render = () =>
status?.update({
text: `${endedAt === null ? '⏱' : '✓'} ${summary()}`,
url: 'command:turn-stats.details',
})
amp.on('agent.start', (event) => {
if (!isActiveThread(event)) {
return {}
}
startedAt = Date.now()
endedAt = null
toolCounts.clear()
clearInterval(ticker)
ticker = setInterval(render, 1000)
render()
return {}
})
amp.on('tool.result', (event) => {
if (!isActiveThread(event)) {
return
}
toolCounts.set(event.tool, (toolCounts.get(event.tool) ?? 0) + 1)
render()
})
amp.on('agent.end', async (event, ctx) => {
if (!isActiveThread(event)) {
return
}
endedAt = Date.now()
clearInterval(ticker)
render()
await ctx.ui.notify(`Turn finished in ${summary()}.`)
})
amp.onDispose(() => clearInterval(ticker))
amp.registerCommand(
'turn-stats.details',
{
title: 'Show Turn Details',
category: 'Turn Stats',
description: 'Show how long the last turn took and which tools it used',
},
async (ctx) => {
if (startedAt === 0) {
await ctx.ui.notify('No turn has started yet.')
return
}
const breakdown = [...toolCounts]
.sort((a, b) => b[1] - a[1])
.map(([tool, count]) => `${tool}: ${count}`)
.join('\n')
await ctx.ui.confirm({
title: 'Last Turn',
message: `${summary()}\n\n${breakdown || 'No tools were called.'}`,
confirmButtonText: 'OK',
})
},
)
} Example Plugin: /goal
This plugin gives Amp a /goal command: it keeps the agent working toward a goal instead of
stopping to report after each slice of work. Goal: Set asks for the goal with ctx.ui.input(...) and appends it to the thread. On each agent.end, the plugin asks amp.ai.ask(...) whether the
last assistant message shows the goal is fully reached. If not, it returns continue with a
follow-up message. Goal: Clear stops the loop, and so does cancelling a turn.
By default Amp stops chaining plugin continue messages after five in a row. This plugin needs
more, so it sets maxContinuations on each continue result and pauses at its own cap of 50
instead, asking you to send a message. Any message from you starts a fresh chain, and the goal
stays set until it is reached or cleared.
Save this as .amp/plugins/goal.ts, then run plugins: reload. Open the command palette and run Goal: Set.
import type { PluginAPI, ThreadID, ThreadMessage } from '@ampcode/plugin'
const marker = '[plugin:goal]'
// Amp allows five chained plugin `continue` messages by default. This plugin raises that limit
// through `maxContinuations` on each `continue` result and pauses at its own cap instead. Any
// message from the user starts a fresh chain.
const maxChainedContinues = 50
interface Goal {
text: string
chainedContinues: number
}
const lastAssistantText = (messages: ThreadMessage[]): string => {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message?.role !== 'assistant') {
continue
}
const text = message.content
.filter((block) => block.type === 'text')
.map((block) => block.text)
.join('\n')
.trim()
if (text) {
return text
}
}
return ''
}
export default function (amp: PluginAPI) {
const goals = new Map<ThreadID, Goal>()
amp.registerCommand(
'goal.set',
{
title: 'Set',
category: 'Goal',
description: 'Keep the agent working until a goal is reached',
},
async (ctx) => {
if (!ctx.thread) {
await ctx.ui.notify('No active thread. Send a message first, then set a goal.')
return
}
const text = await ctx.ui.input({
title: 'Goal',
helpText: 'Amp keeps working after each turn until this goal is reached.',
submitButtonText: 'Set Goal',
})
if (!text?.trim()) {
return
}
goals.set(ctx.thread.id, { text: text.trim(), chainedContinues: 0 })
await ctx.thread.appendUserMessage({
type: 'user-message',
content: `${marker} Work toward this goal until it is fully reached. Do not stop to ask whether to continue.\n\nGoal: ${text.trim()}`,
})
},
)
amp.registerCommand(
'goal.clear',
{ title: 'Clear', category: 'Goal', description: 'Stop continuing toward the current goal' },
async (ctx) => {
if (ctx.thread && goals.delete(ctx.thread.id)) {
await ctx.ui.notify('Goal cleared.')
return
}
await ctx.ui.notify('No goal is set for this thread.')
},
)
amp.on('agent.end', async (event, ctx) => {
const goal = goals.get(event.thread.id)
if (!goal) {
return
}
// Cancelling a turn is how the user stops the loop.
if (event.status !== 'done') {
goals.delete(event.thread.id)
await ctx.ui.notify(`Goal stopped: turn ${event.status}.`)
return
}
// A turn the user started (not one of our continue messages) resets the chain.
if (!event.message.includes(marker)) {
goal.chainedContinues = 0
}
if (goal.chainedContinues >= maxChainedContinues) {
await ctx.ui.notify(
`Goal paused after ${maxChainedContinues} continues. Send any message to keep going, or run Goal: Clear.`,
)
return
}
const report = lastAssistantText(event.messages)
const check = await amp.ai.ask(
[
`An agent is working toward this goal:\n${goal.text}`,
`Its latest report:\n${report || '(no text)'}`,
'Is the goal fully reached, with nothing left to do? Partial progress or a plan for remaining work means no.',
].join('\n\n'),
)
if (check.result === 'yes') {
goals.delete(event.thread.id)
await ctx.ui.notify(`Goal reached: ${check.reason}`)
return
}
goal.chainedContinues += 1
return {
action: 'continue',
userMessage: `${marker} Not done yet (${check.reason}). Continue working toward the goal without stopping to report until it is reached.\n\nGoal: ${goal.text}`,
maxContinuations: maxChainedContinues,
}
})
} Example Plugin: Kitchen Sink
For a single plugin that exercises the core plugin surfaces — events, commands, tools, UI, and AI helpers — see the Kitchen Sink example on the Plugin API reference page.
Acknowledgment
Amp’s plugin API is inspired by pi’s extension API, created by the awesome genius Mario Zechner.
See the Plugin API reference for the full @ampcode/plugin type reference.