Tutorials · Extensions
Build a trusted runtime extension
Create a real Autohand Code extension that users run with /deploy. Along the way you will add a stateful Ink menu, status and help content, a keyboard shortcut, a CLI flag, a lifecycle hook, a provider, and permission policy.
Intermediate · About 35 minutes
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
--trustdecision 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.mjsTypeScript 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 doctorValidation 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-assuranceConfirm extensions:ready in the status line and ctrl+k deploy in the help line. Then run the daily command:
/deploy productionMove 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 --yesThe 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 --yesAfter 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.