How Amp plugins work

Follow one small plugin from a TypeScript file to a command a coding agent can actually use.

Puck observes a visual model of the Amp plugin lifecycle as the article advances.

Follow one small plugin from a TypeScript file to a command a coding agent can actually use.

A plugin can make a coding agent feel as if it learned something new in an instant. You install one, a command appears, and it works. But the agent still has to find the plugin, start it, learn what it provides, and keep talking to it while that command runs.

To make that process concrete, we will use Amp, one of the best coding agents out there, and follow one of its plugins from start to finish. This is a close look at Amp's child-process model, not a claim that every coding agent loads plugins in exactly the same way.

Let us start from the very beginning and follow one plugin step by step. Our plugin asks the user to choose a fruit and then shows the selection in a notification. That is all it does.

Real plugins can add commands, tools, skills, event handlers, and modes. A small example is more useful here because we can see every part of the process.

01 The plugin file

The user creates fruit.ts in a place where Amp looks for plugins. The file registers one command called fruit.pick. When the command runs, it opens a selector and waits for the user to choose a fruit. Then it shows the result.

fruit.ts
import type { PluginAPI } from "@ampcode/plugin";

export default function (amp: PluginAPI) {
  amp.registerCommand(
    "fruit.pick",
    {
      title: "Pick a fruit",
      description: "Choose a fruit and show it",
    },
    async (ctx) => {
      const fruit = await ctx.ui.select({
        title: "Pick a fruit",
        options: ["Apple", "Banana", "Cherry"],
      });

      if (fruit) {
        await ctx.ui.notify(`You chose ${fruit}.`);
      }
    },
  );
}

The file is easy to read, but the command does not exist in Amp yet. Amp still has to find the file, start the plugin, and learn what it registered.

02 The agent starts another process

In our example, plugin loading starts when the Amp client starts. The user runs amp, and Amp discovers fruit.ts.

This gives us two processes: the main Amp process and a child process for the fruit plugin. Each ordinary plugin gets its own child process.

Amp creates the child by making a temporary directory, writing its plugin runtime there, and executing its Bun binary again with BUN_BE_BUN=1. It passes the runtime script and the path to fruit.ts to that new process.

Amp keeps a handle to the child process. Through that handle it can write to the child's standard input and read from its standard output.

03 The two processes communicate

Amp uses stdin and stdout to exchange messages with the plugin process.

Amp uses its own RPC messages, encoded as one JSON object followed by a newline. The object describes a request, response, or event; the newline marks where one message ends and the next begins. Here, RPC describes the request-and-response pattern, not a claim that Amp uses the formal JSON-RPC wire protocol.

The format is less important than the picture it gives us. There are two processes, and anything they need from each other has to cross this connection as a message.

04 The child loads the plugin

The child does not import fruit.ts immediately. First it asks Amp for information about the client it is joining.

Amp replies with its server address, the workspace root, the kind of executor running the plugin, and the authenticated user when one is available.

Only then does the child import the plugin file. It takes the default export and calls it with an amp instance created by the runtime. This is the same amp value used by amp.registerCommand in our example.

While the child imports the file and runs setup, Amp waits up to 10 seconds for a ready message. If that message does not arrive, Amp treats the load as timed out. A plugin file can exist and even import successfully without becoming a working capability.

Starting fruit.ts

Swipe or use the arrow keys to see the full sequence.

sequenceDiagram
    participant Amp as Amp process
    participant Plugin as Fruit plugin process
    Plugin->>Amp: Request client information
    Amp-->>Plugin: Server, workspace, user, executor kind
    Plugin->>Plugin: Import fruit.ts
    Plugin->>Plugin: Call default export with amp
						

Both the import and the function call happen inside the child. Amp never sources fruit.ts into its main process.

05 The agent learns what the plugin added

When fruit.ts calls registerCommand, the runtime stores the command and its handler inside the child process. It then tells the main Amp process that the command list changed, and plugin setup continues.

After the plugin's default function finishes, the child sends a ready message. Amp can now ask for every command, tool, skill, event, and mode the plugin registered. For our example, Amp receives fruit.pick, adds it to the command palette, and connects that command to the fruit plugin process. That connection is how Amp knows where to send the request when the user runs it.

Registering fruit.pick

Swipe or use the arrow keys to see the full sequence.

sequenceDiagram
    participant Amp as Amp process
    participant Plugin as Fruit plugin process
    Plugin->>Plugin: Store fruit.pick and its handler
    Plugin-)Amp: Command list changed
    Note right of Plugin: Plugin setup continues
    Plugin-)Amp: Plugin is ready
    Amp->>Plugin: Request registered capabilities
    Plugin-->>Amp: fruit.pick and other registrations
    Note left of Amp: Bind fruit.pick
						

06 The user runs the command

The user opens the command palette and runs "Pick a fruit." Amp knows that fruit.pick belongs to our plugin, so it sends a request to run the command to that child process.

The child finds the command handler and calls it with a context. The first interesting line is ctx.ui.select(...). The plugin cannot draw the Amp interface itself, so this call sends a request back to the main process.

Amp shows the selector. The user chooses Banana, and Amp sends that value back to the promise waiting inside the plugin. The next line calls ctx.ui.notify(...). That becomes another request to Amp, which displays the notification. The plugin can then finish the original command request.

Running Pick a fruit

Swipe or use the arrow keys to see the full sequence.

sequenceDiagram
    actor User
    participant Amp as Amp process
    participant Plugin as Fruit plugin process
    User->>Amp: Run Pick a fruit
    Amp->>Plugin: Run fruit.pick
    Plugin->>Amp: Show a fruit selector
    Amp->>User: Apple, Banana, or Cherry?
    User-->>Amp: Banana
    Amp-->>Plugin: Banana
    Plugin->>Amp: Show You chose Banana
    Amp->>User: Display notification
    Amp-->>Plugin: Notification displayed
    Plugin-->>Amp: Command finished
						

The plugin decides what it wants to ask for. Amp owns the interface and the interaction with the user. Both directions travel through the same RPC connection.

07 What happens when it fails?

A failure before the ready message is a load failure. Amp may have started the process and imported the file, but the plugin did not finish startup. Its command never becomes available.

A crash after readiness is different because the plugin was already active. Amp removes its capabilities and can restart the child up to three times after the initial run. The attempts use backoff instead of repeating immediately in a tight loop.

The process boundary matters here. An exception in the fruit plugin does not run inside Amp's main process. Amp sees the child exit and handles that plugin as failed.

08 How does it stop?

During a normal unload, reload, or shutdown, Amp first removes the plugin's capabilities. If the child reached readiness and is still running, Amp asks it to clean up its resources and reply.

Amp waits about three seconds for that reply. If the child stays alive, Amp asks the operating system to interrupt it, waits again, and can force the process to stop as the final step. A plugin that crashes or is forced to stop does not get a chance to clean up.

09 Why use this model?

Amp keeps each ordinary plugin across a process boundary and communicates with it through RPC. That boundary fits Amp's executor model: the plugin can run where the executor is running, on the client or in an Orb, while Amp continues to communicate with it through messages.

Global plugins change how a plugin is distributed and who can use it, but they still execute on the client or Orb side.

Amp also has declarative server-only agents and tools. They are a separate system, so the child-process lifecycle in this article should not be applied to them.

The supported plugin surface and lifecycle boundaries are documented in the Amp Plugins guide and the Plugin API reference.

A plugin is not just a file that becomes part of an agent. It has a lifecycle and a working relationship with the system that loaded it.

In Amp, that relationship lives across a process boundary and is carried by data and messages. The same model repeats for every ordinary plugin it loads. Add another plugin and Amp starts another child process, with its own connection, readiness state, capabilities, and failure boundary.

That is the useful picture to keep. The plugin gives the agent another capability without becoming part of its main process. Amp starts it, talks to it, and makes its commands or tools available once the plugin is ready.