$extension-builder create a runtime showcase with /deploy, an Ink deployment menu, ctrl+k, a provider, and a permission policy

What you will learn

  • Package compiled JavaScript behind contributes.runtime.
  • Register commands, custom Ink UI, line segments, shortcuts, and flags.
  • Connect session hooks, an extension: provider, and permission policy.
  • Make an informed --trust decision and prove the complete lifecycle.
  • Use the installed extension every day without invoking $extension-builder.

Before you start

  • Autohand Code: use a build that includes trusted runtime Extension API v1.
  • A safe workspace: run the walkthrough in a disposable Git repository.
  • Source review: runtime extensions execute inside the Autohand process and are not sandboxed.

--trust grants code execution. A trusted entrypoint has the same operating-system access as Autohand. Permission contributions govern actions routed through Autohand; they do not restrict arbitrary extension code.

Step 1: Scaffold the package

Create a package with a compiled runtime directory. This tutorial uses JavaScript directly so there is no build step.

mkdir -p autohand.runtime-showcase/dist
cd autohand.runtime-showcase
touch README.md autohand.extension.json dist/extension.mjs

TypeScript authors can keep src/extension.ts, import the public ExtensionRuntimeAPI type from autohand-cli, and compile to dist/extension.mjs. Autohand does not transpile TypeScript or install package dependencies.

Step 2: Declare the runtime

Save this manifest as autohand.extension.json:

{
  "$schema": "https://raw.githubusercontent.com/autohandai/code-extensions/main/schema/autohand.extension.schema.json",
  "schemaVersion": 1,
  "extensionApi": 1,
  "id": "autohand.runtime-showcase",
  "name": "Runtime Showcase",
  "version": "1.0.0",
  "description": "Demonstrate trusted runtime Extension API v1 capabilities.",
  "license": "Apache-2.0",
  "contributes": {
    "runtime": ["dist/extension.mjs"]
  }
}

Runtime paths must remain inside the package and end in .js, .mjs, or .cjs. Validation reads the file but never imports it.

Step 3: Build the command and Ink view

Start dist/extension.mjs with a stateful view and a command that opens it:

export async function activate(api) {
  const { React, Ink } = api.ui;

  function DeploymentView({ close, workspaceRoot, environment }) {
    const choices = ['Plan deployment', 'Validate release', 'Cancel'];
    const [selected, setSelected] = React.useState(0);

    Ink.useInput((_input, key) => {
      if (key.upArrow) {
        setSelected(current => (current - 1 + choices.length) % choices.length);
      } else if (key.downArrow) {
        setSelected(current => (current + 1) % choices.length);
      } else if (key.return) {
        const choice = choices[selected];
        close(choice === 'Cancel'
          ? 'Deployment cancelled.'
          : `${choice} selected for ${environment}.`);
      }
    });

    return React.createElement(
      Ink.Box,
      { flexDirection: 'column', marginTop: 1 },
      React.createElement(Ink.Text, { color: 'green' }, 'Trusted runtime extension active'),
      React.createElement(Ink.Text, null, `Target: ${environment}`),
      React.createElement(Ink.Text, { dimColor: true }, `Workspace: ${workspaceRoot}`),
      React.createElement(Ink.Text, { dimColor: true }, 'Use arrows and Enter. Escape closes.'),
      ...choices.map((choice, index) => React.createElement(
        Ink.Text,
        { key: choice, color: selected === index ? 'cyan' : undefined },
        `${selected === index ? '❯' : ' '} ${choice}`,
      )),
    );
  }

  api.ui.registerView({
    id: 'autohand.runtime-showcase.deploy',
    title: 'Deployment console',
    component: DeploymentView,
  });

  api.commands.register({
    command: '/deploy',
    description: 'Open the extension deployment console',
    execute(context) {
      const environment = context.args[0]
        || context.cli.getOption('deployEnvironment')
        || 'staging';
      return context.ui.open('autohand.runtime-showcase.deploy', { environment });
    },
  });
}

Use the React 19 and Ink 7 instances exposed by api.ui. Bundling another React or Ink copy can break hooks. Autohand owns modal pause/resume, Escape, Ctrl+C, and terminal cleanup.

Step 4: Add line content, a shortcut, and a flag

Add these registrations inside activate, before its closing brace:

api.ui.setStatusLine({
  segments: [
    { id: 'runtime-showcase-status', text: 'extensions:ready', color: 'success' },
  ],
});

api.ui.setHelpLine({
  segments: [
    { id: 'runtime-showcase-help', text: 'ctrl+k deploy', color: 'accent' },
  ],
});

api.keybindings.register({
  key: 'ctrl+k',
  command: '/deploy',
  when: 'input-empty',
});

api.cli.registerFlag({
  flags: '--deploy-environment <name>',
  description: 'Default deployment environment',
  defaultValue: 'staging',
});

Commands, flags, shortcuts, providers, views, and segment IDs cannot collide with built-ins or another extension. A collision rejects the complete activation instead of leaving partial state.

Step 5: Register a lifecycle hook

Use the existing hook contract to add context when a session starts:

api.hooks.on('session-start', () => ({
  additionalContext: 'The runtime showcase is active. Use /deploy for its deployment console.',
}));

Runtime hooks share deterministic ordering and the normal hook response contract. Disabling or removing the extension removes its hook immediately.

Step 6: Add an extension provider

Providers use the reserved extension: namespace. Add this deterministic local provider inside activate:

api.providers.register({
  name: 'extension:showcase',
  displayName: 'Showcase Provider',
  create(config) {
    let model = config.model;
    return {
      getName: () => 'extension:showcase',
      async complete(request) {
        const lastMessage = request.messages.at(-1);
        const content = typeof lastMessage?.content === 'string'
          ? lastMessage.content
          : 'an Autohand request';
        return {
          id: `showcase-${Date.now()}`,
          created: Math.floor(Date.now() / 1000),
          content: `Showcase provider (${model}) received: ${content}`,
          finishReason: 'stop',
          raw: { provider: 'extension:showcase', model },
        };
      },
      listModels: async () => ['showcase-local'],
      isAvailable: async () => true,
      setModel: nextModel => { model = nextModel; },
      getModel: () => model,
    };
  },
});

A production provider can read provider-owned settings from extensionProviders. Keep credentials in user configuration or environment variables, never in the extension package.

Step 7: Contribute permission policy

Add one narrow allow and one explicit deny:

api.permissions.registerPolicy({
  allowList: ['run_command:git status --short'],
  denyList: ['run_command:npm publish'],
});

Permission policy applies only to actions routed through Autohand. The immutable security blacklist is checked first and cannot be overridden by an extension, unrestricted mode, or user configuration. An extension cannot replace the session permission mode or decision cache.

Step 8: Validate, review, and trust the package

Return to the package parent, validate without execution, review the entrypoint, then make the trust decision explicitly:

autohand extensions validate ./autohand.runtime-showcase
sed -n '1,240p' ./autohand.runtime-showcase/dist/extension.mjs
autohand extensions install ./autohand.runtime-showcase --trust
autohand extensions show autohand.runtime-showcase
autohand extensions doctor

Validation is safe to automate. It checks the manifest, paths, runtime file type, and package limits without importing the runtime. Only trusted installation allows activation.

Step 9: Use the extension in Autohand

Start Autohand with the extension flag:

autohand --deploy-environment quality-assurance

Confirm extensions:ready in the status line and ctrl+k deploy in the help line. Then run the daily command:

/deploy production

Move through the deployment menu with the arrow keys and press Enter. With an empty composer, press ctrl+k to reopen it using quality-assurance from the CLI flag. Press Escape to close it safely.

Daily usage is direct. Users run /deploy or the registered shortcut. $extension-builder is an authoring skill, not the runtime command.

Step 10: Configure the provider and prove policy

Select the provider in ~/.autohand/config.json:

{
  "provider": "extension:showcase",
  "extensionProviders": {
    "extension:showcase": {
      "model": "showcase-local"
    }
  }
}

Start a fresh session and send a harmless prompt to confirm the provider response. Then ask Autohand to run git status --short and confirm the exact allow entry. Ask it to run npm publish and confirm denial occurs before execution.

Step 11: Test disable, enable, and removal

Lifecycle operations must remove every runtime registration cleanly:

/extensions disable autohand.runtime-showcase
/deploy
# Expected: Command /deploy is not supported.

/extensions enable autohand.runtime-showcase
/deploy staging

/extensions doctor
/extensions remove autohand.runtime-showcase --yes

The top-level commands are equivalent when Autohand is not running:

autohand extensions disable autohand.runtime-showcase
autohand extensions enable autohand.runtime-showcase
autohand extensions remove autohand.runtime-showcase --yes

After disable or removal, the slash command, view, line segments, shortcut, flag, hook, provider, and permission policy must no longer be active.

What you learned

  • Declared a compiled runtime entrypoint without executing code during validation.
  • Built a slash command and stateful Ink interface that preserve terminal behavior.
  • Registered line content, a shortcut, flag, hook, provider, and permission policy.
  • Installed with explicit trust and used the extension directly with /deploy.
  • Proved that disable and removal clean up every registration.

Try next

$extension-builder adapt this runtime showcase into a release extension with /release, a version picker, and a provider-backed changelog summary