Skip to content

EYAS v0.8.15-beta — Designs your agents follow

Choose a tag to compare

@eyssen eyssen released this 27 Aug 11:06
· 1 commit to main since this release

A design stops being a picture you keep somewhere else and becomes something the
work follows. Multi-artboard canvases in the Claude Design format, rendered by
EYAS's own MIT runtime: create one, import one, edit it by hand or on the canvas
or by asking, version every change through a single validator, attach it to a
conversation or a project, and export it to PNG and PDF.

Getting an agent to actually use one took longer than building it, and that is
most of what follows. The tool inventory was clipped to 15% of itself. A matched
skill emptied the tool list. Nothing that was ever written to memory could reach
a prompt, and nothing wrote to memory either. Each was invisible on its own, and
together they were why a design sat attached to a conversation and changed
nothing.

Design (F2)

  • A "Design" menu item. Multi-artboard canvases on a pan-and-zoom surface, in
    the Claude Design container format: <Name>.dc.html artboards, a canvas.json
    layout manifest with pages and sticky notes, and images stored as bare base64
    under their filename. A canvas exported here re-seeds there, and one published
    there imports and renders here.
  • EYAS's own runtime. The hosting platform's editor is a ~2.4 MB precompiled
    payload under a licence this repository cannot redistribute, so the Design
    Components dialect is implemented from scratch as MIT code: dotted-path holes,
    <sc-for>, <sc-if>, <dc-import>, JSX-camelCase event binding, and real
    execution of the artboard's class Component extends DCLogic — so clickable
    prototypes, variant switches and selection state work.
  • The isolation that makes executing AI-authored JavaScript acceptable: a
    srcdoc iframe with sandbox="allow-scripts" and never allow-same-origin, a
    CSP inside the srcdoc with connect-src 'none', and Google Fonts as the only
    external origin. No route serves an artboard as a document — the render endpoint
    returns the srcdoc and the sandbox value in one JSON payload so they cannot
    drift apart. The runtime moves <helmet> content into <head> but drops any
    <script> there.
  • A validator gate on every write. Hand edit, import or AI result, all of it
    is checked before it can become a version: an artboard with no <x-dc> root, a
    layout entry naming a file that is not there, an image reference with nothing
    behind it, a case-insensitive artboard-stem collision, a launch pointing at
    nothing, a stray top-level key in canvas.json, and the }} ? ternary inside a
    style attribute that the format drops silently. A rejected edit leaves the
    previous version byte-identical.
  • One AI pipeline, not one per vendor. The same prompt and the same gate
    whatever the provider; only the executor tier varies — whole-canvas rewrite for
    small canvases, per-artboard iteration for large ones, both on plain text
    completion so a local model works too. A failed attempt is retried once with the
    validator's own output as the feedback.
  • Agents get design_list, design_read, design_write and design_create,
    all category: 'custom' so they survive the MCP bridge and exist on the CLI
    providers. A design linked to a conversation travels with every turn as a
    design-context section; a large canvas is summarised and the agent fetches what
    it needs.
  • Import from a published canvas page, export as raw files, as a portable canvas
    document, or as a standalone HTML page that opens and prints anywhere.

WYSIWYG (F4)

  • Click an element, change it in a panel, and it lands in the source.
    Typography, colour, box, border, radius and layout, including grid tracks that
    round-trip through repeat(N, minmax(0, 1fr)). Text is editable in place
    unless it is bound to a {{hole}}, which the panel says rather than silently
    overwriting the binding.
  • The design that this forced. The artboard iframe has no
    allow-same-origin, so the app cannot reach its DOM. Rather than parse and
    mutate the template in the app — which would need a server-side DOM and a
    rendered-node-to-source mapping — the runtime owns the mutation: it stamps
    every template element with a stable index at parse time, applies the edit to
    its own copy, re-serialises, and posts the finished template back. The app
    splices it into the .dc.html file with the head marker, helmet and logic
    script preserved byte-for-byte.
  • Style edits keep {{holes}} in declarations they did not touch. The patch
    works on the style attribute as text, declaration by declaration; a DOM style
    API would have destroyed the binding silently.
  • The splice refuses anything that does not read back as what was written.
    Checking that the result merely parses is too weak: a </x-dc> inside a
    template closes the element early, and the file still parses — into a
    truncated artboard.
  • Messages are attributed to the artboard's own frame before they are acted
    on, and validated against a strict shape. They come from an opaque origin and
    are exactly as untrusted as the artboard.
  • Tweak chips from data-props re-render live; pinning one writes it back as
    the artboard's declared default.
  • Undo/redo per artboard with Cmd/Ctrl+Z, and one version per explicit save
    rather than one per keystroke.
  • The runtime defaults to interact, not edit: the canvas shows working
    prototypes, and an artboard marked is_interactive never enters edit mode.

Print, PDF and PNG (F5)

  • A design canvas exports as PNG and PDF. One artboard at 1× or 2×, one
    artboard as a PDF at its own natural size, or the whole canvas as a single
    multi-page PDF. print: 'fixed' artboards come out as one page at exactly
    their frame — a CSS pixel is 1/96 inch, so the size passes through without a
    conversion; print: 'flow' artboards paginate onto A4 or Letter.
  • The browser fact that shaped it: Chromium will not paginate inside an
    iframe.
    It lays a frame out as a fixed box and clips the overflow, so a
    flowing artboard printed in the preview's sandboxed iframe would come out as
    one truncated page. Every artboard is therefore rendered as its own top-level
    document, and a canvas PDF is those PDFs concatenated with pdf-lib. That is
    the better answer anyway: each page keeps its natural size, a flowing report
    still paginates, and one artboard's <helmet> CSS cannot leak into the next.
  • Losing the sandbox attribute meant replacing it with three things. Every
    print page opens in a throwaway browser context with no cookies and an opaque
    origin; every request is aborted in the browser process except the two Google
    Fonts origins the format admits; and the page carries the same ARTBOARD_CSP
    as the preview, imported rather than re-typed so the two cannot drift.
  • A broken artboard is refused, not exported blank. Both failure layers are
    checked — the mount throwing, and the runtime's own marker when a component
    constructor or renderVals() throws. A PDF whose only content is
    "renderVals() threw: …" is worse than an error message.
  • The browser is optional and says so. playwright-core is a real
    dependency (Apache-2.0, no postinstall, no runtime dependencies of its own);
    the ~150 MB browser binary is not. It is resolved from EYAS_CHROMIUM_PATH,
    then Playwright's own registry, then known system paths, and when there is
    none /api/v1/designs/print-status answers available: false with the remedy
    and the UI disables the buttons. The Docker image installs Chromium and the
    fonts a headless browser needs; deleting that layer costs ~350 MB and switches
    these two features off cleanly.
  • The Chromium sandbox is never disabled automatically. A sandbox failure
    does not fall back to --no-sandbox: the renderer is the process that
    executes AI-authored artboard JavaScript, and turning a deployment problem
    into a silent security downgrade there is not a trade-off worth making
    quietly. It takes an explicit EYAS_CHROMIUM_NO_SANDBOX=1, and the error
    message says so.
  • playwright is gone as a shimmed optional module. The browser tools and
    the print pipeline now share one resolver, so there is a single place that
    knows how to find a Chromium, and the SSRF predicates moved to
    shared/net-guard where the headless browser can apply them per request.
  • New dependencies: playwright-core (Apache-2.0) and pdf-lib (MIT). The
    latter is not in the design spec's dependency list — it was added because
    concatenating per-artboard PDFs is what makes a mixed canvas correct instead
    of compromised onto one uniform paper.

Canvas usability, and one field taken back out

  • The canvas takes a scroll wheel. Plain scroll pans, Shift scrolls
    sideways, Ctrl/⌘ + scroll zooms — anchored on the pointer, so the thing under
    the cursor stays under the cursor. The listener is attached natively with
    passive: false, because React routes onWheel through a passive root
    listener where preventDefault() is ignored and Ctrl+wheel zooms the browser
    instead.
  • An artboard can be opened on its own. A control on its title row (or a
    double-click on the title) fits it to the viewport; Esc returns to the
    previous view. This is what finally makes artboardEntry.expand do something:
    fit shrinks the whole artboard to the viewport, fill widens the frame to
    the viewport at natural scale and lets it scroll.
  • "Fit" now fits. It measures the page's actual bounding box — artboards and
    annotations — instead of resetting to a hardcoded 60% at 40,40.
  • Nothing was put over the frames. An overlay would make the entire surface
    pannable, but it would also silence every is_interactive prototype until you
    clicked into it. Dragging the background works, so opening an artboard is an
    explicit control rather than a gesture over a frame.
  • A design can be renamed in place from its header.
  • designs.status is gone. It rendered a badge in the list and did nothing
    else: it gated nothing, no UI ever called its filter, and it could not be
    changed from anywhere. A declared surface with no consumer — removed rather
    than given a job it did not need. Existing installs keep the column inert
    (NOT NULL DEFAULT 'draft', so an INSERT that omits it still works); a table
    rebuild is not worth it, and a test pins that the old shape still works.

Deleting a design, and watching a long AI edit

  • A design can be deleted from the interface. The capability had been in
    the API since the canvas shipped, with no button anywhere — the manual said
    so out loud. The bin sits in the detail header beside rename, not on the list
    cards: those are links, and a destructive control inside a navigation target
    is a misclick waiting to happen.
  • The confirmation names what goes with it — saved versions, and every
    conversation or project the design is attached to. The attachment count is
    the part nobody can see from the design's own page, so GET /designs/:id now
    carries a links summary next to the design. Asking blind is the worse
    version of asking.
  • A nine-minute AI edit no longer looks like nothing happening. A measured
    edit on a CLI provider took 8 min 43 s behind a bare spinner. The panel now
    counts the elapsed time, says that a large canvas can take minutes, and the
    AI button in the header spins while a run is open — so it is visible without
    opening the panel.
  • A failed AI edit keeps its reason. Every attempt is recorded before the
    model is asked and closed on every exit, including the throw, so a reload, a
    dropped connection or a proxy timing the request out no longer destroys the
    answer: the server finishes, the row records it, the panel reads it back. The
    request itself stays synchronous — turning it into a job id would only
    shorten the HTTP hold, at the cost of the existing API and the candidate
    preview path.
  • A restart is not a model failure. A run orphaned by a dead process is
    closed as interrupted with its own message when the module registers,
    rather than spinning forever or being reported as something the AI got wrong.
  • Two clocks again, handled at the source. "How long has this been running"
    subtracts a server timestamp from a browser one. The runs response carries the
    server's own now, the frontend derives the skew from it, and the elapsed
    figure is measured on one clock. The columns are epoch milliseconds rather
    than this module's usual ISO text, because datetime('now') produces a string
    new Date() reads as local time.
  • A second AI edit cannot start while one is running on the same canvas.

Attaching a design where it is actually used

  • A design can be attached to a conversation from the UI — an icon in the
    top bar with a count, not a field: the bar is full, and this is an occasional
    act.
  • And to a project, which every conversation created in it then inherits.
  • A project's designs are COPIED onto a conversation created in it, the same
    way indexedSources and workingDirectories already are — set on the
    project, and a new conversation starts with them; not set, and it does not.
    The conversation owns them from then on and can detach any one, and nothing
    resolves the project again at read time. The copy is additive and idempotent,
    so it never removes a design somebody attached on purpose.
  • design_link and design_unlink tools, so an agent can attach the canvas
    it just made. They default to the run's own conversation from the tool
    context, never a model-supplied id, and scope is a closed namespace —
    conversation or project — so a model cannot file a link somewhere nothing
    reads it.
  • Background runs see attached designs. buildDesignContext was wired into
    interactive chat only, which meant a scheduled run worked blind on the very
    design it was supposed to be working from.

What an agent sees of an attached design

  • A design announces itself; it does not hand its contents over. The block
    says a design is attached and what KIND of data each of its parts holds —
    tokens, typography, components, patterns — and names the two calls that fetch
    them. On the shipped Odoo canvas that is 652 characters against a 46 763
    character design
    , and it stays flat: twenty artboards fold into the same
    handful of role lines as two.
  • design_read takes a part. It used to return either one whole artboard
    or the entire canvas, so "read only what you need" meant reading 10 KB to
    find five hex codes. A part returns the derived values for one role and
    nothing from another, off the same derivation the announcement is built from
    — the two cannot disagree about what exists.
  • Nothing is inlined, at any size. An earlier version put the palette in the
    block, and before that the whole canvas when it fit. Both were paid on EVERY
    turn; a fetch is paid once. At two turns the fetch already wins, and only the
    fetch stops the cost growing with the canvas.
  • The block instructs rather than announces. It says to follow the design
    and not to invent styling, instead of merely noting that one is attached.
  • The index is derived, never stored. A stored copy would be a second thing
    to keep in sync, would travel in exports, and would confuse the Claude Design
    interop.
  • The design prompt teaches the same role vocabulary, so newly generated
    canvases name their artboards accordingly — the structure is the design's own,
    not a classification imposed afterwards. Artboards are classified by file name
    and title, never by body text: a body that mentions "pattern" proves nothing.
  • Two regex traps found on the first real design: JavaScript's \b is
    ASCII-only, so \bűrlap never matches "Űrlapelemek"; and Hungarian lengthens
    the stem vowel in the plural, so "minta" is not a prefix of "minták".

Foundation (F0)

  • One assembly path. assembleSystemPrompt is now the single fail-soft "assemble and flatten"
    helper, and executeAgent plus channel-run-agent go through it. Delegated subagents and
    channel replies get the project cascade and the workspace files they were silently missing. The
    agent definition's own prompt is appended, never replaced, so nothing that worked stops working.
  • A missing SOUL.style.json no longer empties the prompt. The active-voice resolver ran inside
    the assembler's Promise.all; one agent without that file made buildForPrimary throw and the
    interactive path send system: ''. It now degrades to a neutral profile.
  • projectId reaches where it was lost: orchestrator children, ToolContext, and
    ConversationService.create().
  • New entry points in the context inspector: delegated and channel. A channel reply now
    records what its prompt was made of, which it never did before.
  • Route-scoped body limits. The 1 MiB global cap is now raised per prefix. This also fixes
    document uploads, which believed they allowed 50 MB while HTTP rejected them at 1 MiB.
  • Public asset route. <dataDir>/public is served with Cross-Origin-Resource-Policy: cross-origin, so an EYAS-hosted image can finally load in an email client or an exported page.
    Binary types only — SVG is refused on an origin that holds the session cookie.

Outgoing HTML, without a second entity to configure

  • A deterministic renderer owns every byte of the HTML EYAS sends
    notification email, channel replies, and the body of an approved email draft,
    composed at SEND time rather than frozen into the draft row.
  • It takes Markdown, never HTML. That is the security property, not a
    convenience: accepting markup would need a sanitizer, and instead the body
    goes through the existing escape-by-construction markdown renderer while the
    shell is built in code. A caller that passes HTML gets a refusal that says to
    send Markdown.
  • The notification email channel stops building its own HTML. It routed
    around the template engine with an unescaped inline string — which is why
    render() and registerTemplate() had zero callers. Both the escaping bug
    and the bypass are gone, and the transport is injectable so the send path is
    actually tested.
  • ChannelContent.html is finally populated. It had been plumbed all the
    way to MIME since the channel layer was written and never filled in.
  • render_html_document tool, so an agent can turn Markdown into a
    self-contained page or email body through the same renderer.

The brand entity, considered and removed

  • A second entity (design_systems: palette, typography, tone, logo) was built
    alongside designs, with its own table, CRUD, versioning, project column,
    settings card, brand_get tool, compliance critic and app skin. It is
    gone.
    Two adjacent concepts — "brand" and "design" — are misleading side by
    side, and what was useful about it the design already carries: an attached
    canvas puts its artboard source in the prompt, so the agent sees the colours,
    the type and the components.
  • What survived, and why: the deterministic HTML renderer above. Its palette
    is a constant now. Everything else went with the entity — the compliance
    critic, the app skin, the URL extraction, the brand-context prompt section
    and the 800 tokens carved out for it (projectCascade gets its full 3000
    back; the total is still 8400).
  • Existing installs keep two inert columns (projects.design_system_id,
    conversations.design_system_id) that nothing reads; a table rebuild is not
    worth it.

Tools that reach the agent, and output you can find

  • The design tools reached nobody. They were registered, both MCP bridges
    would have served them, and every agent's allow-list had been written before
    the design module existed. The symptom was a model saying "design_read is
    not wired", working around it, and producing the result twice — once without
    the design and once with the palette it scraped out of the prompt.
  • Seeded agents are brought up to the tool set their template grants today.
    Same shape as the design-prompt seed migration: keep every previously shipped
    tool set, upgrade only rows that still match one exactly. An agent whose
    tools somebody edited is left alone, because that was a decision.
  • Every module that registers tools now declares tools as a dependency.
    Eight did not. Registration is (ctx as any).tools?.registry — an optional
    chain that gives up silently — so a module ordered before the tools module
    loses its tools with no error anywhere. It worked only because bootstrap
    happens to register the tools module earlier in the file. A contract test now
    runs the real loader over the real dependency graph.
  • The MCP bridge logs the tool list it serves. Without it, "did the model
    actually get that tool" is archaeology.
  • A conversation always has a working directory. With none, the agent
    picks: observed output landed in /tmp and on the Desktop in the same
    session. When neither the request nor the project names one, the conversation
    gets a directory of its own under the data directory.
  • What an agent writes hangs off the message that produced it, so it is
    visible in the conversation as it happens and still there after a reload —
    not only in a side panel. The outputs are collected before the assistant
    message is stored and attached to it.
  • A non-image attachment is finally visible. Every attachment rendered as
    an <img> that hid itself on error, so an HTML page, a PDF or a CSV became
    an invisible broken image: an agent could write a file, register it
    correctly, and still leave no trace anywhere a user looks. Images still
    render as images; everything else is a chip with its name, its size and a
    link that opens it.
  • Background runs collect their output too, not only interactive chat — a
    scheduled report otherwise existed only on disk.
  • The token figure says what it counts. "299 931 token" next to a one-line
    question reads as if EYAS had sent that much context. It is what the provider
    billed for the run, and a CLI agent re-sends everything it has read on each
    of its internal turns, so it is the sum of those. The label and its tooltip
    now say so. (The number itself was correct — this is a labelling fix, not an
    accounting one.)
  • Two clocks, one comparison. Output collection compared Date.now() with
    the filesystem's mtime and required strictly newer. A fast run writes its
    file in the same millisecond, some filesystems keep mtime only to the second,
    and the file was dropped — a test that passed alone and failed in a warm
    suite. There is a two-second tolerance now: wider than any rounding, far
    narrower than the age of anything already in the workspace.
  • What an agent writes also shows up in the attachments panel. A CLI
    provider writes with its own file tool, so there is no write_file call to
    intercept and nothing lands in the documents table. Instead, after a turn,
    the working directory is scanned for files newer than the turn and they are
    registered as documents on the conversation. Deliberately conservative: two
    directory levels, no build or checkout directories, an allow-list of
    extensions a person would open, and a cap — a working directory can be a
    repository, and a build must not become four thousand attachments.

The tool list the model was shown was 15% of the tool list

  • 56 tools rendered to 13 586 characters against a 2 000-character budget, so
    the model saw eight.
    Every design_* tool was among the 11 517 characters
    dropped, and the clip landed mid-sentence — taking with it the line that tells
    the model where the real schemas come from. One half of the prompt referenced
    tools the other half did not list. The observable result: an agent narrating
    "the design tool may have a different name, I'm looking for it", writing the
    requested page without the attached design, and writing it again once it had
    worked the palette out from the design index instead.
  • Descriptions are now the first thing given up, and names the last. An
    inventory's job is to say what exists; the schemas arrive over the provider's
    tool API, which the footer states. Names-only puts all 56 tools in about
    1 000 characters — the complete list, inside the same budget.
  • The footer survives at every size, because it is where the model learns
    the descriptions it is no longer being shown are available elsewhere.
  • Nothing trails off. If even the names do not fit, the section says how
    many it could not list. That is the case for skills today: 228 of them need
    roughly 3 700 characters against a 1 600-character bucket, so about a third
    are named and the rest are counted. A 228-entry inventory in a system prompt
    is its own problem, now visible instead of silently cut.

A skill has to be accepted, and never takes the tools away

  • "Make a simple HTML file that shows the time" matched the
    google-drive-integration skill at 0.9
    , was injected silently, and the same
    code path emptied the tool list — if (activeSkill) tools.length = 0.
    design_read was named in the prompt and in the tool inventory and was not
    callable, so the agent read a stale file off disk and produced the wrong
    design. Verified from the recorded prompt: the inventory was complete and
    untruncated, the design announcement was there, and the tools never arrived.
  • The tool-stripping rule is gone, and was not narrowed to one skill type
    either. An integration skill exists to be used WITH tools; a tool skill IS
    one; a knowledge skill is reference material, which is no reason an agent
    should stop being able to read a file. Nothing tested it and nothing depended
    on it.
  • A matched skill is now a proposal, and the turn waits for the answer.
    Nothing is streamed, no assistant message is written and the model is not
    called: the run stops at the match, which happens before the stream opens.
    The user's message stays stored — it really was sent.
  • The answer is two buttons, and the card says WHY the skill matched: name,
    score and the matched pattern. On the failure above it would have read
    Google Drive · 0.9 · name: Google Drive on a request to print the time.
  • Both answers are remembered, per conversation. Accepting applies the skill
    silently from then on, because it was approved; declining means it is never
    proposed again there. A skill can be right for one conversation and wrong for
    the next, so the decision is not global.
  • Resuming is a re-run, not a suspended request. Holding an SSE connection
    open until a human clicks would survive neither a restart nor a closed tab.
    The client re-sends with resume: true, which skips storing the user message
    a second time — pinned by a test that counts the messages across the stop and
    the resume.
  • The background path proposes nothing. There is nobody to ask, so it uses
    what was already accepted and otherwise runs without a skill.
  • Without a decision store no skill is applied at all. A decision that
    cannot be recorded would be asked for again every turn, and injecting
    silently instead is the exact bug this closes.
  • The weak match that started it is left standing as a separate problem — a
    skill scoring 0.9 on an unrelated request means the matcher needs work — but a
    bad match now costs a click, not every tool and a wrong answer.

Every tool call spun for ever on a finished run

  • The ACP client knew which tool call had ended and threw the id away. It
    emitted tool_use_start with the real toolCallId and then a bare
    tool_use_end — no id, no status — so the panel could never match an end to
    a start. Seven tool rows kept spinning under a run badged completed.
  • tool_use_end now carries id and status, optional so the nine providers
    that emit it bare stay valid. The grok-cli path fills both, the SSE relays
    forward them, and the two CLI providers stop guessing the id for their
    orchestration event.
  • Three ways a call can settle, in order of trust: the tool_result that
    names it, the tool_use_end that names it, and — for a provider that names
    nothing — the oldest still-running call, because these CLIs run their tools
    one at a time. A first outcome always stands; a late end cannot turn a
    reported success into an error.
  • And a last line of defence: finishing a run settles anything still
    running. A completed run cannot have a tool call in flight.

Memory the model can actually see

  • The vault reaches the prompt. A durable note — a markdown file with
    frontmatter — now contributes one line to an index the agent gets on every
    turn, on both the interactive and the background path. Before this, no memory
    tier had any path into the prompt except a tool nobody called, so a memory
    system that had been empty for 24 conversations would have stayed invisible
    even once it filled.
  • Two frontmatter fields carry it: kind (user, feedback, project,
    reference) and summary. user and feedback rank first because they
    change how every answer is produced.
  • A note with no kind is never read as user. It becomes feedback under
    procedural/ and reference otherwise. Promoting an undeclared note to a
    fact about the owner would put it at the top of every prompt on a guess.
  • A hand-written Obsidian note works as-is. With no summary, the note's
    first real line becomes its index entry — no EYAS-specific frontmatter needed
    for the vault to be useful.
  • The index says what it is. A note's body is conversation text replayed
    into a system prompt later, which is a delayed prompt-injection channel; the
    block is labelled background context, not instructions.
  • Nothing is silently cut. Whole lines are dropped to fit the budget and the
    count of what did not fit is printed. Half a summary is noise the model has to
    guess at.
  • Per turn, not a cache-prefix sectionDEFAULT_BUDGET_FULL sums to 8400
    against a shrink target of 8800, so a new prefix section would quietly scale
    every other section down for every agent.
  • Capture is deliberately not in this change. The vault holds exactly what
    was put there on purpose.

The user manual

  • The design chapter is rewritten in all six languages
    (knowledge/design.md). It had grown by appending a section per feature and
    read like a build log: "Editing" and "Editing on the canvas" as separate
    topics, panning documented twice, "Import and export" competing with
    "Printing and export", and — worse — a stale claim that a small canvas is
    injected whole and a large one summarised, which stopped being true when the
    index landed.
  • Eleven sections in a reading order, identical across the six languages:
    creating, getting around, opening one artboard, the three ways to edit,
    tweaks, versions, naming artboards so an agent can find them, attaching,
    what an agent actually sees, exporting and printing, and renaming and
    deleting.
  • Writing the chapter is what found the missing delete button. A "what is
    not there yet" section had to exist to admit it. Both are gone now — the
    button was built, and the section it needed had nothing left to say.

Fixed along the way

  • triage.ts called require() three times in an ESM codebase.
    COMPLEXITY_TO_TIER and CATEGORY_TIER_OVERRIDE are runtime constants that
    had been placed in an import type — which erases them — so three call sites
    reached for require('./types.js') to get them back. That resolves only once
    something else has loaded the module, so any chat-route test running on its
    own died with "Cannot find module './types.js'" while passing in a warm suite.
    They are a value import now.

Security

  • Artboard HTML never gains allow-same-origin; the public asset route serves an
    allow-list of binary types only, with nosniff and an explicit CORP header, because it bypasses
    Hono and therefore every security-header middleware.
  • DOMPurify added (taken under its Apache-2.0 option) for the HTML surfaces that follow.

Known issues

  • The 58 pre-existing test failures across 9 files (test-fixture drift) are unchanged.
  • The design source editor is a plain textarea, matching the workspace file editor
    already in the app. A real code editor is deliberately deferred rather than
    introducing a second editing paradigm in the same release.
  • The agentic executor tier — a CLI provider editing the materialised canvas with
    its own file tools — is designed but not wired; it needs the agent-runner
    integration. chooseTier never returns it, and nothing pretends otherwise.
  • bun run full-docs regenerates every documentation page from the generator's metadata and
    destroys hand-written prose in the process. The committed docs and the generator have
    diverged; the design documentation was therefore hand-written into the six pages rather
    than generated.
  • The skill matcher scores badly. google-drive-integration matched a request
    to print the time at 0.9, against a 0.1 threshold, out of 228 enabled skills.
    The acceptance gate means a bad match now costs a click instead of a wrong
    answer, and it is what finally makes the matcher's real quality measurable —
    but the scoring itself is untouched.
  • 228 skills do not fit an inventory. Their names alone need roughly 3 700
    characters against a 1 600 character budget, so about a third are named and
    the rest are counted. Honest, and still a lot of skills to put in front of a
    model on every turn.
  • Durable memory does not fill itself yet. Recall works — a note in the vault
    reaches every prompt as one index line — but nothing writes one automatically.
    The vault holds exactly what was put there on purpose.
  • The permission bridge has no deterministic working-directory check. Its
    gate is model-judged, so it refused one write to the project root and allowed
    another; an agent told to write only under its workspace put a file in the
    repository root anyway. A path comparison would settle it.