tucan studio

Docs / Developer documentation

Browse topics
On this page

Building a Tucan Studio extension

An extension adds menu items, side-panel forms, and file editors to Tucan Studio without recompiling the IDE. Everything you need to write one lives in the workspace folder: a manifest, JS scripts, and XML form templates.


Quick start

  1. Create a folder for your extension — call it whatever you like (hello-world, my-cool-thing, etc.). Extension DevTools → Create new extension does this for you, manifest and example script included.

  2. Open that folder in Tucan Studio (File → Open workspace folder...).

  3. Drop a file called extension.json at the root:

    {
      "name": "Hello World",
      "description": "First extension.",
      "localName": "hello-world",
      "permissions": [],
      "menuItems": [
        { "name": "Say hi...", "script": "hello.js" }
      ]
    }
    
  4. Alongside it, drop hello.js:

    alert("Hi from my extension!");
    
  5. Reload the extension list (Extensions → Reload extensions). Your extension shows up as Extensions → Hello World → Say hi... — it's live, no install step required.

That's it. The rest of this document walks through what else the manifest can express and what your scripts can do.


The workspace layout

While you are writing it, a flat folder is enough:

<workspace-root>/
├── extension.json          -- REQUIRED. The manifest.
├── *.js                    -- action scripts, one per menu item
├── *.form.xml              -- form templates that scripts open
└── *.view.xml              -- file-editor view templates (optional)

The moment the extension needs a binary, it needs the shipping layout instead, because a binary is per-platform and a script is not:

<workspace-root>/
├── extension.json
├── shared/                 -- platform-independent: scripts, forms, data
├── platforms/
│   ├── amigaos/            -- e.g. bin/mytool (m68k)
│   ├── morphos-ppc/        -- e.g. bin/mytool (PPC)
│   └── macos-arm/
└── dist/                   -- produced by Build; see below

Nothing lists which files ship: the layout is the list. Extension DevTools → Build copies the contents of shared/ into dist/, then the contents of platforms/<platform>/ on top, then extension.json. Contents, not the folders themselves — shared/js/app.js becomes dist/js/app.js and platforms/amigaos/bin/tool becomes dist/bin/tool, so both trees merge into the single drawer a user installs.

Platform files are copied second and therefore win a path collision, which is how a shared default is replaced by a real binary where one exists. With no platforms declared shared/ is all there is; with one it is used automatically; with several Build asks which.

Once dist/ exists the studio runs your workspace extension from dist/, not from the files you are editing — so a menu item exercises exactly what a user gets, binaries included, and an edit does not take effect until you build again. Build overwrites and never deletes: a file dropped from shared/ lingers in dist/ until you clear it.

Everything under the workspace root is your extension's sandbox. studio.fs.read/write/exists calls in your scripts see only files inside this folder — good for storing per-extension state (connections.json, apikey.txt, whatever) without leaking into the wider system.

When the IDE is launched with your workspace open, extension.json turns your workspace into a live extension — its menu items land under the Extensions menu alongside any installed extensions. Edit a script, hit Extensions → Reload extensions, and the next click runs the new code. There's no install / rebuild step while you're developing.

For distribution you either commit the folder to an aminet-style archive and point a catalog entry at it, or you copy it into a user's .studio/extensions/<localName>/ dir manually. See Publishing at the bottom.


extension.json — the manifest

Each extension folder carries an extension.json describing what the extension does — its menus and file-editor bindings. This is the file the IDE reads to wire up an installed (or workspace-open) extension.

{
  "name":        "Human-friendly name shown in the menu",
  "description": "One-line summary shown in the Extensions manager",
  "localName":   "kebab-case-id",
  "permissions": ["internet", "system.execute"],
  "menuItems":   [ ... ],
  "fileEditors": [ ... ]
}

Field-by-field:

Field Required Meaning
name yes Label of the top-level submenu under Extensions.
description no Blurb shown in the Extensions manager UI.
localName yes Unique identifier; must match the folder name.
permissions no Allowed sensitive APIs. See Permissions. Default: none.
menuItems no Actions shown in the extension's submenu. See Menu items.
fileEditors no File-extension → editor bindings. See File-editor extensions.
skills no Skill documents for the AI agent, as paths inside your drawer. See Skills for the AI agent.

all-extensions.json — the catalog index

extensions/all-extensions.json is a slim index the Extensions manager browses — discovery + how-to-fetch metadata only. The actual behaviour (menus, file editors) lives in each extension's own extension.json (above), which the IDE folds in once the extension's folder is present. A catalog entry looks like:

{
  "name":             "BebboSSH",
  "description":      "One-line summary shown in the browse list",
  "type":             "local | aminet | github",
  "remoteIdentifier": "comm/net/bebbossh",
  "localName":        "bebbossh",
  "platforms":        ["amigaos", "morphos-ppc"],
  "archiveHasOwnDir": true,
  "onInstall": {
    "amigaos":     ["Path tucan:extensions/bebbossh ADD"],
    "morphos-ppc": ["Path tucan:extensions/bebbossh ADD"]
  }
}
Field Meaning
name / description Shown in the browse list before install.
type local (bundled), aminet (downloaded from Aminet) or github (reserved).
remoteIdentifier Where to fetch a non-local extension from.
localName Ties the entry to its extensions/<localName>/ folder + manifest.
platforms Runtime platforms the extension supports (amigaos, morphos-ppc, macos-arm, linux-x64, …). The IDE hides the entry on any platform NOT in the list; omit (or []) for "all platforms".
archiveHasOwnDir (aminet) the downloaded archive already wraps its files in a <localName>/ dir.
onInstall Platform-keyed shell commands run once at install (and re-run each boot on Amiga). A { "<platform>": ["cmd", …] } object; only the current runtime's list runs.

A type: "local" extension needs no remoteIdentifier — its code ships in extensions/<localName>/ and "install" just registers it.


Each entry in menuItems becomes one click target. Four shapes:

1. Run a shell command

{ "name": "Compile with GCC", "command": "gcc ${file}", "interactive": false }

${…} placeholders can be filled from a form (see below). Set interactive: true to open a CLI panel and let the child process inherit stdin, e.g. for interactive tools like bebbossh.

2. Run a shell command that FIRST prompts via a form

{
  "name":     "Generate SSH key...",
  "command":  "bebbosshkeygen -C \"${comment}\" -f \"${outFile}\"",
  "form":     "keygen.form.xml",
  "interactive": true
}

form points at an XML file (see Forms) whose <TextField id="comment"/> etc. values fill the ${…} placeholders.

3. Run a JS script

{ "name": "Ask OpenAI...", "script": "ask.js" }

The script runs in a fresh JS runtime with the studio.* bridge wired up. See JS scripts.

4. Run an am-script panel script

{ "name": "Database...", "script": "dbtool.ams" }

A .ams menu script runs on the am-script host instead of the JS one, with a different (smaller, statically checked) surface aimed at tool panels. See am-script panel extensions.

5. Create a new file

{ "name": "New sprite set...", "newFile": ".dss" }

Prompts for a filename with that extension and opens it. Pairs naturally with a fileEditors entry so the new file lands in your custom editor. See File-editor extensions.

Dynamic submenu from data

{
  "name": "Connect to",
  "menuFrom": {
    "source":  "connections.json",
    "label":   "${name}",
    "command": "bebbossh ${user}@${host}",
    "interactive": true
  }
}

source is a JSON array in your sandboxed asset dir; each element becomes a submenu item with label's ${…} placeholders filled from its fields, and clicking runs the templated command.


Forms (*.form.xml)

Minimum viable form:

<?xml version="1.0" encoding="utf-8"?>
<Form title="Say hi">
  <VStack spacing="6">
    <Label text="Name:" />
    <TextField id="name" default="${suggested}" />
    <HStack spacing="4">
      <Button text="Cancel" onClick="cancel" style="secondary" />
      <Button text="OK"     onClick="ok" />
    </HStack>
  </VStack>
</Form>

Available widgets:

Tag Attributes Notes
<Label text="..."/> text, growX, growY Static text row.
<TextField id="..." default="..."/> id, default, growX Single-line editor.
<DropDown id="..." options="a,b,c"/> id, options (CSV), default Pick-one control.
<CheckBox id="..." default="true"/> id, default ("true"/"false") Toggle.
<Button text="..." onClick="handler" style="secondary"/> text, onClick, action, navigate, appendTo, style See below for handler routing.
<ImageButton id="..." src="icon.png" onClick="handler"/> id, src, width, height, onClick Clickable image; icon can be swapped from JS.
<Image id="..." src="pic.png" width="200" height="100"/> id, src, width, height, scaleToFit, maintainAspectRatio Static image; swap at runtime via form.image(id).setImage(...).
<HStack spacing="N">…</HStack> / <VStack spacing="N">…</VStack> spacing Horizontal / vertical layout.
<Spacer/> growX, growY Flex-fill; both grow default to 1.
<List id="..." source="items.json"><Row>…</Row></List> source (JSON file), spacing Data-bound row template; each JSON element fills one row via ${field} placeholders.
<Row onClick="handler" field="sel" value="${id}"> onClick, field, value, command, interactive Row click calls a script handler. It writes value into the field named by field first, so the handler can read which row from values — markup rows cannot pass an argument. The clicked row highlights; the rest clear.
<Hidden id="..." default="..."/> id, default A registered field with no on-screen presence — where a <List> picker keeps its selection. Reads back through values like any other field.
<Tabs selected="N">…<Tab title="...">…</Tab></Tabs> selected (0-based) Tabbed pages. selected picks which opens, so a script rebuilding its window can return the user to the tab they were on.
<PixelBuffer id="..." width="16" height="16" paletteSize="16"/> Indexed 8-bit canvas For file-editor extensions; scriptable via studio.editor.pixelCanvas(id).
<JsCanvas id="..."/> Freeform surface Custom paint / mouse callbacks.
<WrappedTextView/> Multi-line word-wrapping text Put inside a Panel with a preferredWidth to cap wrap width.
<View name="page1">…</View> Named page for multi-page forms Switch with form.navigate("page1").

${placeholder} tokens in any attribute value are filled from the data object your JS script passes to studio.form.open.

Button routing: an onClick="foo" looks up handlers.foo in the studio.form.open config; action="submit" / action="cancel" map to the built-in Submit/Cancel handlers; navigate="page2" swaps to that <View>; appendTo="listId" pushes the current field values into the named <List>.


JS scripts

Each script runs top-to-bottom, once, in a fresh JS runtime. Top-level state disappears when the runtime tears down — persist via studio.fs.write to a sandboxed JSON file. The runtime stays alive as long as some closure (typically a form's handler) holds a reference to it, so studio.form.open scripts stay live until the form dismisses.

studio.fs

Sandboxed to the extension's own folder. Relative paths resolve there; absolute paths are refused.

studio.fs.exists(path)          // Bool
studio.fs.read(path)            // String — text only
studio.fs.write(path, content)  // void

studio.form

studio.form.open({
  source:      "myform.form.xml",     // XML file in the extension dir
  data:        { key: "value" },      // fills ${placeholder} in the XML
  placement:   "leftSidebar",         // see below
  width:       400,                   // pixels; see placement table
  height:      300,                   // pixels; see placement table
  dockPercent: 30,                    // % of screen axis (sidebars/bars)
  handlers: {
    ok:    function (values, form) { form.close(); },
    close: function (values, form) { /* fires on X-button */ }
  }
});

Placement / sizing:

placement Where it mounts Respects width Respects height
"leftSidebar" (default) Left column ✓ (pixel width) — (always full column)
"rightSidebar" Right column ✓ —
"topBar" Top strip — ✓
"bottomBar" Bottom strip — ✓
"window" Free-floating draggable modal ✓ ✓

When dockPercent and pixel width/height are both set, the pixel values win.

Handler signature: every handler receives (values, form) — values is a plain JS object mapping every field id to its current value; form is a handle object with:

form.close()               // dismiss the form
form.navigate("page2")     // switch to <View name="page2">
form.getValues()           // snapshot current field values
form.setValue(id, text)    // push a value into a field (returns bool)
form.image(id)             // ImageHandle — see below
form.setOptions(id, "a,b,c")   // replace a <DropDown>'s options

form.setOptions is for a picker whose choices depend on another field — markup fixes options once, at build time. The current selection survives if the new list still contains it, otherwise the first option is selected.

form.setValue(id, text) updates a field by id and returns true if one matched. Works on <TextField> (sets text), <CheckBox> ("true"/"false"), and <DropDown> (selects the matching label). Unknown id → false, no throw. Handy after a native file pick:

browse: function (values, form) {
    var path = studio.openFile("Select a file");   // native requester
    if (path) { form.setValue("filePath", path); } // null on cancel
}

Special handler names:

  • close — invoked when the user clicks the window's X button (placement: "window" only). If defined, the script decides whether to actually dismiss (call form.close()); if not defined, the X button auto-closes.

form.image(id) — swap the picture at runtime

var pic = form.image("hero");   // ImageHandle for <Image id="hero"/>
pic.setImage("banner.png");                   // path relative to extension dir
pic.setImage("/abs/path/banner.png");         // absolute path
pic.setImage("https://example.com/pic.png");  // remote — requires "internet"
pic.clear();                                  // reset to blank 1×1

Only PNG is supported. Remote URLs are downloaded synchronously via the same WebClient path studio.http uses; the response is staged in a temp file, decoded, then the temp file is deleted. On any failure (missing file, HTTP non-2xx, decode error) the current image is left in place and one diagnostic line goes to the CLI.

aiProviders — add an AI service to the chat

An extension can contribute a provider to the AI Chat's service list, so a model the studio has no built-in client for becomes selectable next to OpenAI and Claude. Declare it in extension.json:

"aiProviders": [
  { "id": "google",
    "name": "Google AI",
    "script": "provider.js",
    "contextWindow": 1048576,
    "models": ["gemini-2.0-flash", "gemini-2.5-pro"] }
]

id is persisted against every service and chat session that uses it, so treat it as permanent once published; renaming it orphans the user's configured service. models populates the model dropdown.

The script calls studio.ai.register() exactly once with five functions:

studio.ai.register({
    endpoint:   function (ctx) { return "https://api.example.com/v1/chat" },
    headers:    function (ctx) { return { "x-api-key": ctx.apiKey } },
    buildBody:  function (ctx, messages, tools) { return JSON.stringify({...}) },
    parseReply: function (body) { return { content: "...", toolCalls: [], promptTokens: 0 } },
    parseError: function (body) { return "..." }
});

ctx carries apiKey, model, workspaceId, hostedWebSearch and hostedCodeExecution. messages is [{role, content, toolCallId, name, toolCalls}] with roles system / user / assistant / tool. tools is [{name, description, parameters}], where parameters is the JSON schema as a string — JSON.parse it.

parseReply may return a plain string (just the answer) or an object. In toolCalls, arguments must be a JSON string — JSON.stringify your object, since it goes straight to the tool executor.

ToolCall.id is opaque to the studio: it only pairs a tool result back to its call. A provider whose API needs extra per-call state returned to it can therefore pack that state into the id and unpack it when replaying history — which is how google-ai carries Gemini's mandatory thoughtSignature through a turn. State kept in a script-level variable instead would not survive the provider being rebuilt on a session switch.

Two things a provider script deliberately cannot do:

  • No I/O. The studio global here exposes only studio.ai and studio.platform — no fs, cli, http or forms. The studio makes the request on the script's behalf, which is why these are pure functions and why a provider needs no internet permission. It also means the retry, error and token-accounting paths stay shared with the built-in providers.
  • No blocking. The hooks run on the main thread, one call at a time.

A provider that fails to load (missing file, no register() call, syntax error) degrades to an error message in the chat rather than taking the studio down. extensions/google-ai is a complete worked example.

studio.http

Requires "internet" in the manifest's permissions. Synchronous — the AmLang main task blocks until the response arrives.

var r = studio.http.get(url, { "Header": "Value" });
var r = studio.http.post(url, body, { "Content-Type": "application/json" });
// r = { status, statusText, body, headers }

body is a string (already-serialised JSON). r.body is a UTF-8 decode of the response body; binary responses aren't supported by studio.http — use form.image().setImage(url) for images.

Async form — pass a callback. Add a function as the last argument and the call returns immediately; the request runs on the IO worker and your callback is invoked on the main thread with the same response object plus an error field.

studio.http.get(url, { "Header": "Value" }, function (res) {
    if (res.error !== "") { alert("failed: " + res.error); return; }
    handle(JSON.parse(res.body));
});
studio.http.post(url, body, { "Content-Type": "application/json" }, function (res) { ... });
// res = { status, statusText, body, headers, error }

The headers argument stays optional — studio.http.get(url, cb) works.

Guarantees worth relying on:

  • Your callback fires exactly once, always on the main thread. A failure (DNS, TLS, refused, malformed) arrives as error set and status: 0 rather than as a thrown exception, and a request that hangs is timed out after 30s by the IDE's watchdog. There is no path where the callback silently never runs.
  • Callbacks are invoked one at a time on the main thread, so two in-flight requests can't run your JS concurrently — the interpreter is single-threaded and stays that way.
  • At most 32 requests may be in flight at once; going over throws.

Prefer the async form for anything user-visible. The synchronous form freezes the whole IDE for the duration of the request, which on a slow link is seconds of dead UI.

studio.openFile / studio.readFile — pick & read any file

var path = studio.openFile("Select a file");  // native ASL / NSOpenPanel
// -> absolute path string, or null if the user cancelled

if (path) {
    var text = studio.readFile(path);          // UTF-8 text, NOT sandboxed
}

studio.openFile(title?) pops the platform's native single-file requester (ASL on Amiga/MorphOS, NSOpenPanel on macOS, GTK on Linux) and returns the chosen absolute path (or null on cancel). Platforms without a native picker (libc, amigaos-sim) return null.

studio.readFile(path) / studio.writeFile(path, text) read and write any path on disk as UTF-8 text, throwing on error. Unlike studio.fs.* (sandboxed to the extension's own dir), these serve file-import / send-file / save-to-download flows where the target is elsewhere on disk. Text only — no binary/base64 yet.

studio.downloadFolder() returns the IDE's configured download path (Settings → the same path the Aminet sidebar uses; RAM: by default). Extensions should drop received / fetched files here rather than inventing a location:

var dir  = studio.downloadFolder();      // e.g. "/Users/me/Downloads" or "RAM:"
var dest = dir + (dir.slice(-1) === "/" || dir.slice(-1) === ":" ? "" : "/") + "file.txt";
studio.writeFile(dest, contents);

studio.confirm

studio.confirm("Delete platform", "Remove 'amigaos'?", function (ok) {
    if (ok) { /* do it */ }
}, "Delete", "Cancel");          // labels optional, default OK / Cancel

Modal yes/no dialog. Asynchronous: the answer arrives in the callback, not as a return value, so put the destructive work inside it. It cannot block and wait — a form handler runs on the UI thread, and blocking there would stop the very loop that has to draw the dialog and deliver the click.

studio.menu

studio.menu.refresh();  // rebuilds the Extensions menu
                        // — call after mutating a data file that
                        // menuFrom reads.

studio.cli

studio.cli.run("gcc foo.c");                  // fire-and-forget, into the panel
var out = studio.cli.capture("amlc new .");   // no panel; you get the output
var out2 = studio.cli.capture("git status", "/path/to/dir");

Requires "system.execute" in the manifest's permissions.

capture is for a command whose RESULT the script has to act on — a scaffold that either produced a project or explained why it didn't. It returns the combined output as a string and mounts no panel, so the script can decide between an error and a success message instead of leaving the user to read a transcript. The optional second argument is the working directory (default: the open workspace).

It is synchronous and runs on the UI thread: the window doesn't repaint until the command exits. That's the right trade for short, decisive commands; anything long-running belongs in studio.tasks.runSteps or the panel.

Use this for a single command whose output belongs in the terminal. For a SEQUENCE of commands, use studio.tasks.runSteps instead — see below for why.

studio.workspace

studio.workspace.root                        // the open project root, "" if none
studio.workspace.read("package.yml")         // string contents
studio.workspace.write("package.yml", text)  // void
studio.workspace.exists("src/Main.aml")      // bool

File IO inside the OPEN PROJECT, as opposed to studio.fs, which is sandboxed to the extension's own drawer. Paths are relative to the project root; absolute paths and .. are refused, because an extension granted workspace.write is trusted with the project, not with the machine.

Reading requires "workspace.read", writing "workspace.write".

This is how a workspace-type script adjusts what a scaffolder produced — the AmLang Toolchain uses it to repoint a new project's amigaos platform at am-cc, keeping the id, version and dependencies amlc new wrote.

studio.tasks

studio.tasks.runSteps("Install NDK 3.2", [
    { label: "Downloading NDK3.2.lha (8.1 MB)",
      command: "tucan:aminet-cli download \"dev/misc/NDK3.2.lha\" \"NDK3.2.lha\"" },
    { label: "Unpacking",  command: "lha -q x NDK3.2.lha ndk/" },
    { label: "Cleaning up", command: "Delete NDK3.2.lha QUIET",
      workingDir: "" }        // optional; defaults to the extension's drawer
]);

Opens a window that names the current step, shows a progress bar, and stops at the first failure with the command, its exit code and its output shown in red. Returns immediately — the window owns the sequence from there.

Prefer it over a run of studio.cli.run calls whenever the steps depend on each other. Queued at the terminal, a failing step looks like any other line of output, and every step after it either runs against a broken state or never runs at all — the studio's own NDK installer shipped that way, and a missing binary presented as a wall of [queued] lines with no explanation.

Requires "system.execute", same as studio.cli.

An optional third argument is a completion callback, called exactly once on the main thread when the sequence has ended (not when the window is closed):

studio.tasks.runSteps("Build on the server", steps, function (result) {
    if (!result.ok) {
        alert("Step " + (result.failedStep + 1) + " (" + result.label
              + ") failed with exit code " + result.exitCode + ":\n\n"
              + result.output);
        return;
    }
    // Every step exited 0: run what was built, in the CLI panel, with the
    // user's keyboard attached.
    studio.cli.runInteractive("builds/app");
});

result is { ok, failedStep, label, exitCode, output }: failedStep is the index of the step that stopped the sequence (-1 on success), label its label, exitCode its exit code — -1 when the command could not be started at all (a missing binary), in which case output holds the spawn error rather than the step's output. Because the callback runs on the main thread it may open forms, show alerts or hand a command to the CLI directly. Closing the progress window does not cancel the sequence; the callback still reports the real outcome.

A step's command is run as a real process, so a binary that is not on the shell's Path must be named by an explicit path. An extension drawer is added to the Path by onInstall, but the studio's own directory is not — reach things there through the tucan: assign (tucan:aminet-cli).

studio.editor (only inside fileEditors onOpen/onSave)

studio.editor.readBytes()               // UByte[] — the file's current content
studio.editor.writeBytes(bytes)         // void — replace the file's content
studio.editor.path                      // full path of the file
studio.editor.pixelCanvas(id)           // PixelCanvasHandle for <PixelBuffer id="..."/>
studio.editor.canvas(id)                // JsCanvasHandle for <JsCanvas id="..."/>
studio.editor.button(id)                // set onClick on a runtime-wired button
studio.editor.saveAs(newPath, bytes)    // used by "Save As..." menu items

PixelCanvasHandle:

pc.getPixel(x, y)          // palette index at (x, y)
pc.setPixel(x, y, idx)     // set palette index
pc.setPaletteEntry(i, r, g, b)
pc.getWidth() / pc.getHeight() / pc.getPaletteSize()
pc.onPixelChanged = function () { /* dirty flag */ };

JsCanvasHandle:

c.paint = function (g) { g.fillRect(0, 0, w, h); /* ... */ };
c.onMouseDown = function (x, y) { /* ... */ };
c.requestRepaint();

See the Sprite Pixler extension for a worked example.

Globals

  • alert(msg) — modal dialog with an OK button. Single-modal at a time; a second call while a dialog is open is queued.

  • console.log(msg) — writes one line to the IDE's CLI panel; mounts the panel if hidden.

  • JSON.parse(str), JSON.stringify(obj[, replacer, indent]) — bridged via am-json. null / undefined in the input round-trip correctly.

  • include(path) — reads a sibling file relative to the extension dir and runs it in the CURRENT runtime, so top-level function foo() {…} declarations become visible to the caller. Useful for factoring shared helpers into a common.js:

    // configure.js
    include("common.js");
    var cfg = loadConfig();   // defined in common.js
    

    Path-safe — .. and absolute paths are refused.

  • new Date() — standard Date methods; Date.now().getTime() is the idiom for a millisecond timestamp (bridged through the AmLang side).


File-editor extensions

When your extension owns a file format, add a fileEditors entry:

"fileEditors": [
  {
    "extension":    ".dss",
    "viewTemplate": "pixler.view.xml",
    "onOpen":       "openDss.js",
    "onSave":       "saveDss.js"
  }
]

Now double-clicking any .dss file mounts your ViewTemplate in the editor pane instead of the text editor. onOpen runs once with a handle to the file bytes; onSave runs on File → Save with the current widget state — you translate to your on-disk format and call studio.editor.writeBytes.

The ViewTemplate is a superset of the form widgets above plus:

  • <PixelBuffer id="..." width="..." height="..." /> — 8-bit indexed canvas; scriptable via studio.editor.pixelCanvas(id).
  • <JsCanvas id="..." /> — freeform surface with per-frame paint and mouse callbacks.
  • <View name="page1">…</View> — declare multiple pages that navigate buttons can swap between.

Full worked example: assets/.studio/extensions/sprite-pixler.

An onOpen / onSave ending in .ams runs on the am-script host instead, with the Editor / Graphics / Canvas / Button wrappers (AmIde/Script/ScriptStudioWrappers.aml). Worked example: assets/extensions/sprite-pixler-ams.


am-script panel extensions

A menu item whose script ends in .ams opens a tool panel driven by am-script. The language is smaller than JS on purpose — it is statically checked at compile time, so a typo in a wrapper call is a compile error rather than a runtime surprise halfway through a session.

The script's class must be called Extension:

class Extension {
    fun onOpen() {
        Studio.open("dbtool.form.xml", "window", "Database")
    }
    fun onButtonClick(id: String) { ... }
}

The host calls, when the script defines them:

callback when
onOpen() once, after the class is constructed
onButtonClick(id: String) a <Button> was clicked, or Enter was pressed in a <TextField> (its own id)
onTreeSelect(treeId: String, nodeId: String) a <Tree> node was clicked
onTreeExpand(treeId: String, nodeId: String) a <Tree> node was expanded — load its children here
onTableSelect(tableId: String, row: Int) a <Table> row was clicked

The wrappers

These six classes are the whole host surface (AmIde/Script/ScriptPanelWrappers.aml):

Studio — the panel and its widgets.

Studio.open(formPath, placement, title)   mount a form as an app panel;
                                          placement is window (default) /
                                          leftSidebar / rightSidebar /
                                          topBar / bottomBar / leftOverlay /
                                          rightOverlay
Studio.navigate(viewName)                 switch <View name="..."> pages
Studio.fieldText(id) -> String            read a TextField / TextArea /
                                          DropDown / CheckBox
Studio.setFieldText(id, text)
Studio.setLabel(id, text)                 an id'd <Label> is a status line
Studio.setButtonText(id, text)
Studio.setMarkdown(id, md)                push into a <MarkdownView>
Studio.table(id) -> Table
Studio.tree(id) -> Tree
Studio.log(msg)
Studio.isOpen() -> Bool

Sys — run something.

Sys.capture(command) -> String            run in the EXTENSION's folder,
                                          return stdout
Sys.captureIn(command, dir) -> String
Sys.run(command) -> Int                   exit code, no capture
Sys.toolPath(name) -> String              how to invoke a binary bundled in
                                          the drawer ("./x" on libc, "x" on
                                          Amiga-likes)
Sys.quote(value) -> String                shell-quote for THIS host — single
                                          quotes on libc, "..." with `*`
                                          escapes on AmigaOS/MorphOS
Sys.extensionDir() -> String
Sys.workspaceDir() -> String              "" when no folder is open
Sys.platform() -> String

Always put user data through Sys.quote. A password or a SQL statement pasted straight into a command line breaks on the first apostrophe, and on AmigaOS the escape character is *, not \.

Fs — the extension's own folder, and nothing else. Relative paths only; absolute paths, volume prefixes and .. are refused. This is where settings that should follow the extension (rather than a workspace) belong.

Fs.readText(path) -> String               "" when missing
Fs.writeText(path, text) -> Bool
Fs.exists(path) -> Bool
Fs.remove(path) -> Bool

Json — read a document; every accessor is total, so a miss returns an empty value rather than throwing.

Json.parse(text) -> Json                  null when it doesn't parse
Json.escape(text) -> String               for building a document by hand
node.has(key) -> Bool      node.get(key) -> Json     node.at(i) -> Json
node.size() -> Int         node.str() -> String      node.num() -> Int
node.bool() -> Bool        node.isNull() / isArray() / isObject() -> Bool
node.keyAt(i) -> String    node.strOf(key) -> String

Table — a <Table id="..."/>. Rows are pushed a cell at a time because am-script has no list literal.

clearColumns()   addColumn(title, width)   clearRows()
beginRow()       addCell(text)             endRow()
reload()         rowCount() -> Int         columnCount() -> Int
selectedRow() -> Int                       cell(row, col) -> String

The widget virtualises its rows — about a screenful of row views exist regardless of the row count — so a large result set is fine.

Tree — a <Tree id="..."/>, expanded lazily.

clear()                    add(parentId, id, label, hasChildren)
removeChildren(id)         expand(id) / collapse(id)
select(id)                 selectedId() -> String
reload()                   count() -> Int
setKind(id, kind) / kindOf(id) -> String   labelOf(id) -> String
setLoaded(id, b) / isLoaded(id) -> Bool

Give nodes path-shaped ids ("c0/appdb/users") and a callback can work out what was clicked without keeping a side table. onTreeExpand fires before the children are shown, so adding them from inside it makes them appear in the same pass — use isLoaded / setLoaded to fetch once.

Two markup tags the panel forms add

<Tree  id="dbtree" indent="12" />
<Table id="results" columns="id:60,name:200" rowHeight="16" />
<TextArea id="sql" rows="5" default="SELECT 1" />

<Table>'s columns is optional — a script that builds its columns from a query result just leaves it off. <TextArea> is a multi-line input, registered as a field, so Studio.fieldText reads it.

Gotchas

  • The script class must not share a name with a wrapper. class Studio in a script collides in the compiler's class table and silently drops the class's synthetic constructor.
  • am-script has no for, no break/continue, no bitwise operators, no +=, and its each is each (list, item). Strings have length(), charCodeAt(i) and substring(start, LENGTH) — no indexOf, no toLowerCase.
  • static var on a script class parses but produces an instance field. Keep state in instance fields.

Worked example: am-mysql/extensions/tucan-studio (the Database panel), with an integration test in tests/AmIde/Tests/DbToolAmsTest.aml that compiles it against these wrappers.


Workspace templates and the Build menu

A templates entry in your manifest scaffolds a project — the studio creates the folder, opens it, then runs your script. What it does not do on its own is populate the Workspace menu, which comes from .studio/menus.json.

To ship a Build menu with your scaffolder, add a menu template whose id matches your template's id, in the studio's workspace-templates/ (the studio's own assets, not your drawer):

{ "name": "AmLang project",
  "menus": {
    "default": [ { "name": "Build", "command": "make", "workingDir": "" } ],
    "amigaos": [ { "name": "Build", "command": "am-make", "workingDir": "" } ] } }

The per-platform key matters on the Amiga family: the native make is am-make, and the same file serves both by naming amigaos explicitly and leaving everything else on default.

Your script decides when the menus land. Call

studio.templateDone();

on the path where the project was actually created. The studio holds the menu half of the template until then, so a form the user cancels leaves no Build menu behind for a project that was never scaffolded. Call it once, after the scaffold succeeded — not at the top of the script, and not on the error path.

Both halves are optional: a template with no menu file of its id applies nothing (and studio.templateDone() is then a harmless no-op), and a menu template with no extension behind it applies as soon as the workspace opens.

Skills for the AI agent

An extension can ship skill documents — the same reusable instruction files the agent reads through get_skills / get_skill. Declare them in the manifest as paths inside your own drawer:

"skills": ["skills/build-amlang-workspace.md"]

Write them for the agent, not the user: what the extension's tools are called, which commands to run, the mistakes that are easy to make, and how to tell a real success from a misleading one.

The agent lists a skill as {"name": "build-amlang-workspace.md", "scope": "extension", "from": "<extension name>"}, and reads it by that file name.

Lifecycle. The files are read out of your drawer while your .installed marker is present. Nothing is copied into the user's own skills folder, so an uninstall (which removes both the marker and the drawer) takes the skills with it — there is no cleanup step to write, and no orphaned copy left behind. Installing folds the manifest in immediately, so a skill is available without restarting the studio.

Precedence. On a name clash, a workspace skill (.studio/skills/) wins over a Personal one, and both win over an extension's — the user's own instructions override what a bundled tool ships. Between two extensions declaring the same file name, the first in catalog order wins, so name the file after your extension's job rather than something generic like build.md.

Paths must stay inside your drawer: a skills entry containing .., a leading / or a volume : is ignored.

Permissions

Sensitive APIs are gated. Declare in the manifest's permissions array:

Permission Unlocks
internet studio.http.get, studio.http.post
system.execute studio.cli.run, studio.tasks.runSteps (shell command execution)
workspace.read studio.workspace.read / .exists (read files in the open project)
workspace.write studio.workspace.write (write files in the open project)

Missing a permission → the corresponding call throws a JS exception with a descriptive message.


Publishing

Once your workspace-based extension is behaving, ship it one of two ways:

  • Aminet-style bundle: zip the workspace folder and register a catalog entry with "type": "aminet" and a remoteIdentifier pointing at the archive. .studio/all-extensions.json in the shipping IDE gets a new object describing it; the Extensions manager fetches it on demand.
  • Local ship: copy the workspace folder into a user's .studio/extensions/<localName>/ alongside a matching catalog entry. That's how bundled extensions (BebboSSH, OpenAI, Sprite Pixler, Studio Transfer) ride along with the Tucan Studio build.

A script-only extension ships exactly what you were editing: no build step, no bundling, and if it works as a live workspace it works after ship.

An extension that carries a binary does not, because the drawer a user installs holds one platform's files, not all of them. Build it first (Extension DevTools → Build) and ship dist/ — that folder is the per-platform drawer, with shared/ and platforms/<platform>/ already merged into it. Build once per platform you publish for.

Publishing from the command line

The native registry-cli can build and publish an extension from extension.json, version.json, shared/, and platforms/<platform>/:

registry-cli build ./my-extension
registry-cli publish ./my-extension --token "$TUCAN_ACCESS_TOKEN"
# Or sign in as part of publishing:
registry-cli publish ./my-extension --username my-user --password "$TUCAN_PASSWORD"

version.json declares the version and resources (platform, archive name, format). The CLI merges shared files with each platform's files, packages them, creates the registry identity and version, and uploads the archives. create registers only the identity; create-version publishes a release for an existing identity. See the CLI README for complete JSON examples and authentication options.

Extension DevTools provides Publish extension... for this flow. Sign in via Tucan Studio > Account, open the extension workspace, then enter a version and release notes. The extension runs the IDE's bundled registry-cli in a progress window and uses the saved Studio session without exposing its token to JavaScript. Existing version.json resources are preserved; a first release gets platform archives generated from the saved manifest platform list. The extension requires system.execute for this operation.

From the Tucan Studio documentation: docs/EXTENSIONS.md