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
- The workspace layout
extension.json— the manifest- Menu items
- Forms (
*.form.xml) - JS scripts
- File-editor extensions
- Permissions
- Publishing
Quick start
-
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. -
Open that folder in Tucan Studio (File → Open workspace folder...).
-
Drop a file called
extension.jsonat the root:{ "name": "Hello World", "description": "First extension.", "localName": "hello-world", "permissions": [], "menuItems": [ { "name": "Say hi...", "script": "hello.js" } ] } -
Alongside it, drop
hello.js:alert("Hi from my extension!"); -
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.
Menu items
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 (callform.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
studioglobal here exposes onlystudio.aiandstudio.platform— nofs,cli,httpor forms. The studio makes the request on the script's behalf, which is why these are pure functions and why a provider needs nointernetpermission. 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
errorset andstatus: 0rather 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/undefinedin 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-levelfunction foo() {…}declarations become visible to the caller. Useful for factoring shared helpers into acommon.js:// configure.js include("common.js"); var cfg = loadConfig(); // defined in common.jsPath-safe —
..and absolute paths are refused. -
new Date()— standardDatemethods;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 viastudio.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 Studioin a script collides in the compiler's class table and silently drops the class's synthetic constructor. - am-script has no
for, nobreak/continue, no bitwise operators, no+=, and itseachiseach (list, item). Strings havelength(),charCodeAt(i)andsubstring(start, LENGTH)— noindexOf, notoLowerCase. static varon 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 aremoteIdentifierpointing at the archive..studio/all-extensions.jsonin 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.