AI Extension Builder — How a Prompt Becomes a Working Extension
This page explains the "Build Extension with AI" feature for contributors: how the launcher-side orchestration and the bundled coding sidecar fit together, the stdio protocol between them, the feasibility gate, the security model, and the packaging constraint that shapes the whole design. For the user-facing how-to, see Use the Create Extension tool.
It builds on the two-tier model: the orchestration is a Tier 1 built-in feature; the actual code-writing happens in a separate sidecar process.
Two halves, one feature
The feature is split across a process boundary because the two halves live in different worlds:
| Half | Where it runs | Responsibility |
|---|---|---|
| Orchestrator (Tier 1) | The launcher WebView (src/built-in-features/create-extension/ai-builder/) |
UI, job state, notifications, deep-links, secret-scan, register + activate |
Sidecar (asyar-ext-builder) |
Its own OS process (asyar-ext-builder/) |
Runs the Claude Agent SDK: feasibility gate, scaffold, code, pnpm build loop, smoke test |
The WebView cannot spawn native processes, run pnpm, or do raw filesystem/shell
work — so the coding muscle has to be a child process. This mirrors how MCP ships
bun/uv sidecars.
digraph ai_builder {
rankdir=LR;
view [shape=box,label="BuildProgressView\n+ buildJobStore"];
orch [shape=box,label="orchestrator.ts\n(event routing)"];
rust [shape=box,label="ext_builder/*.rs\n(spawn + stream + stdin)"];
side [shape=box,label="asyar-ext-builder\n(bun sidecar.js)"];
claude [shape=box,label="claude runtime\n(externalBin)"];
view -> orch;
orch -> rust [label="invoke()"];
rust -> side [label="spawn bun sidecar.js"];
rust -> view [label="asyar:ext-builder:event\n(stdout lines)"];
view -> rust [label="answers (stdin)"];
side -> claude [label="query() spawns"];
}
The Rust bridge (ext_builder/process.rs)
spawns the sidecar, streams each stdout line as the asyar:ext-builder:event Tauri
event, and forwards answers to the sidecar's stdin. The orchestrator parses each
line and drives the single active job in
buildJobStore.svelte.ts
(states working/waiting/done/failed); the progress view is a pure window
onto that store.
The stdio protocol
Communication is newline-delimited JSON, defined identically on both sides
(buildProtocol.ts
↔ sidecar protocol.ts).
| Direction | Event | Meaning |
|---|---|---|
| sidecar → launcher | verdict |
feasibility result, emitted before any file is written |
step |
progress label for the live view | |
ask |
a question — pauses the job, fires a notification deep-link | |
done |
extension built + verified (carries id, path, smoke summary) | |
fail |
a step failed (carries step, error, log) | |
| launcher → sidecar | answer |
the user's reply to an ask (written to stdin) |
cancel |
abort the build |
Questions are events, not a phase. Whether the agent needs a clarification
early or deep in the build, it emits ask; the job goes to waiting, a
notification deep-links the user to the view to answer, and the answer resumes the
job. The user can leave and return freely — the job outlives the view.
Feasibility gate
The gate is the sidecar's first action and is emitted as a verdict before any
file is written — so an impossible request stops having touched nothing. It
reasons against a curated capability spec
(capabilitySpec/capabilities.json),
which is kept honest by a test that fails if it drifts from the SDK's authoritative
VALID_PERMISSIONS (asyar-sdk/cli/lib/manifest.ts). The capability list and the
authoring guide stay local (the gate's correctness must not depend on a network
fetch).
Knowledge from live URLs
The agent's example knowledge is not bundled. knowledgeSources.json (inlined
into sidecar.js at build) lists canonical raw-GitHub URLs of real example
extensions + docs; the agent WebFetches them on demand for patterns (non-fatal if
unreachable). The rule: live-fetch only what already exists elsewhere; keep local
what doesn't (the capability/permission contract has no canonical external URL, so
it stays local).
Security model
The build inherently runs author-controlled code (pnpm install runs the agent's
package.json scripts), so containment is layered:
- Bash command allowlist. Bash is deliberately kept out of the Agent SDK's
allowedTools(tools inallowedToolsbypass the permission hook) and routed through acanUseToolhook backed by a fail-closed allowlist (isAllowedBashCommandin the sidecar'sutils.ts): onlypnpm/npmbuild subcommands +mkdir/ls, neverdlx/exec/install-from-URL. This contains prompt-injection from fetched docs. - Secret scan in Rust. A build-time third-party key (used only for the verify
smoke test) is scanned out of the generated files by
ext_builder/secret_scan.rs— Rust, not the WebView, because the build dir (~/AsyarExtensions/<id>) is outside the WebView's filesystem capability allowlist. If the key appears verbatim in any file, the build fails closed. - Path-traversal guards on the chosen extension id, in both the sidecar and the
Rust
register_dev_extensioncommand (defense in depth). - Residual:
pnpm run buildstill executes the agent's build script. This is the same risk as building any cloned repo; it's surfaced in-UI. OS-level sandboxing (e.g.sandbox-exec) is a tracked follow-up, not yet implemented.
Packaging constraint (read before touching the sidecar)
The Claude Agent SDK's query() spawns a native claude binary (~215 MB) as a
subprocess — it is not a pure HTTP client. Two consequences shaped the design:
- A
bun build --compilesingle binary does not embedclaude, and even withpathToClaudeCodeExecutableset it can't host the SDK's multi-turn build (in-process MCP + subprocess) from inside the compiled sandbox. - So the sidecar ships as a bundled JS run by the already-bundled
bun:bun build src/main.ts --target bun --outfile dist/sidecar.js(inlines the whole SDK, ~1.6 MB), staged bybuild.rsintosrc-tauri/resources/ext-builder/sidecar.js.process.rsspawnsbun sidecar.js; theclaudebinary ships as a TauriexternalBinand is passed viaCLAUDE_CODE_EXECUTABLE_PATH→options.pathToClaudeCodeExecutable.
Build-order requirement: the release pipeline must run bun run build:js in
asyar-ext-builder/ before tauri build, or an empty placeholder ships and the
feature is dead. tauri dev resolution falls back to CARGO_MANIFEST_DIR/binaries
resourcesso a local run works oncebuild:jshas run and realbun/claudebinaries exist undersrc-tauri/binaries/.
After the build: My Extensions & Publish
- My Extensions (
CreatedExtensionsView.svelte) lists everything in~/AsyarExtensions/via the Rustlist_created_extensionscommand (the scan, sort, and search filter all live in Rust). Per-row actions reuse the open/publish helpers. - Publish to Asyar Store confirms (it creates a public GitHub repo), then opens a
terminal running
asyar publishin the extension's directory — reusing the existing CLI publish flow wholesale rather than reimplementing it.
Source map
| Concern | File |
|---|---|
| Job state machine | ai-builder/buildJobStore.svelte.ts |
| Event routing | ai-builder/orchestrator.ts |
| Question ⇄ notification ⇄ stdin | ai-builder/questionBridge.ts |
| Progress view | ai-builder/BuildProgressView.svelte |
| Protocol types | ai-builder/buildProtocol.ts |
| Sidecar spawn / stream / stdin | src-tauri/src/ext_builder/process.rs |
| Tauri commands | src-tauri/src/ext_builder/commands.rs |
| Enumerate / search built extensions | src-tauri/src/ext_builder/created.rs |
| Secret scan (Rust) | src-tauri/src/ext_builder/secret_scan.rs |
| Sidecar entry + Agent SDK | asyar-ext-builder/src/main.ts, builder.ts |
| Bash allowlist | asyar-ext-builder/src/utils.ts |