Search by

spora-ai / spora-plugin-typst

fabeat

Typst document compilation for Spora agents — backed by ext-typst and the media-derivatives abstraction.

Package info

github.com/spora-ai/spora-plugin-typst

Type:spora-plugin

pkg:composer/spora-ai/spora-plugin-typst

Statistics

Installs: 47

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

v0.1.1 2026-09-08 11:53 UTC

README

Compile Typst source to PDF / PNG / SVG from inside a Spora agent conversation. Backed by ext-typst and the media-derivatives abstraction in spora-core.

What's in the box

  • 2 LLM-callable tools: typst_compile (renders + inspects) and typst_resources (font/template/example/image CRUD per kind).
  • 22 REST routes under /api/v1/typst/{fonts,templates,examples,images,compile,sources}*.
  • 1 admin app (/apps/typst) — manage fonts, templates, examples, and images.
  • 1 agent template (typst-assistant).
  • The typst skill body — covers the workflow, syntax primer, and limit checklist.
  • Tier-1 fonts (Inter OFL + DejaVu Sans/Mono/Serif + Latin Modern Math) shipped under skills/typst/fonts/ — always available without an upload.
  • 1 starter template (skills/typst/templates/report.typ) + 1 example (skills/typst/examples/showcase.typ).
  • A TypstRenderProducer that registers with MediaDerivativeProducerDiscovery so any admin surface can dispatch into it.
  • A TypstSourcePassthroughConverter registered with MediaConverterDiscovery so .typ uploads are accepted (text/x-typst is added to the plugin-supplied allowlist).

Architectural rule — works without spora-plugin-media-archive

The Typst plugin does not depend on spora-plugin-media-archive. Two things make this work:

  1. Direct PHP integration with the derivatives core. The typst_compile tool (action=render) calls MediaDerivativeService::create() directly via the DI container — no HTTP hop into a plugin-only controller. The derivatives surface in chat via MediaEmbed markdown referencing the core's /api/v1/assets/<uuid>.<ext> route (served by core's AssetController, not by any plugin).

  2. Independent admin UI. The /apps/typst admin panel handles Typst resources (fonts, examples) without crossing the Media Archive plugin's namespace. Operators can install spora-plugin-typst standalone.

The Media Archive plugin is a value-add consumer of the derivatives abstraction — its VersionsStrip UI (when installed) renders the Typst derivatives alongside other media derivatives, but the Typst plugin doesn't require it.

Requires

PHP ^8.4.1
ext-typst * (Carthage Software — install via PECL or your distro's package manager)
spora-core >=0.20.0 — the plugin depends on media_assets.principal_id (migration 0075) and media_derivatives (0076).

Bootstrap

composer install
vendor/bin/pest            # runs the plugin's Pest suite
vendor/bin/phpstan analyse # PHPStan level 5

The plugin's composer.json requires spora-ai/spora-core >=0.20.0. The media_assets.principal_id (migration 0075) and media_derivatives (0076) migrations ship in 0.20.0; earlier tags don't include them.

Tests

Tests:    28 passed (78 assertions)
Duration: ~1.8s

The plugin's test suite has two parts:

  • Unit tests under tests/Unit/ — services, producer, world factory. The producer tests skip themselves when ext-typst is not loaded so CI on vanilla ubuntu runners can still run the rest of the suite.
  • Feature test under tests/Feature/ — end-to-end through MediaDerivativeService::create(), asserting natural-key idempotency on the second call.

Tool surface

Tool Operations Notes
typst_compile render / inspect render compiles source → PDF / PNG / SVG and persists as a media-derivative (requires approval). inspect is a read-only error-only pass (auto-approved).
typst_resources fonts / templates / examples / images Each operation picks the resource kind; the op parameter picks the verb (list / write / delete). Read-only ops are auto-approved; write and delete require approval.

typst_compile accepts the source as either:

  • source (inline UTF-8 Typst source) — paired with an optional filename (the playground pool row name; .typ is auto-appended). The parent row always lives in tool_name='typst.playground', so it surfaces in the file picker; filename collisions create a sibling row rather than overwriting the existing source. When filename is omitted, the tool auto-generates one (inline-YYYYMMDD-HHMMSS-XXXX.typ) so a render without an explicit basename still drops a findable row in the playground.
  • file (a previously-uploaded .typ media asset id) — renders the parent bytes. Re-rendering the same (file, format) pair refreshes the existing derivative row. filename is ignored on this path.

file and filename are distinct: file carries a media-asset UUID, filename carries a free-form basename for inline source. LLMs frequently reach for file when they mean filename; the tool auto-defaults in that case rather than rejecting the call.

inspect never persists: it runs inspectString($bytes) only and returns the structured diagnostics. No MediaAsset row is written, so an inspect call cannot leave orphan rows in the playground pool.

REST surface

Method Path Purpose
GET /api/v1/typst/fonts List visible fonts (skill + principal)
GET /api/v1/typst/fonts/{name} Stream a font's bytes
POST /api/v1/typst/fonts Upload a principal-tier font (raw text or base64)
DELETE /api/v1/typst/fonts/{name} Remove a principal-tier font
GET /api/v1/typst/examples List visible examples
GET /api/v1/typst/examples/{name} Stream an example's source
POST /api/v1/typst/examples Upload a principal-tier example
DELETE /api/v1/typst/examples/{name} Remove a principal-tier example

All routes sit behind AuthMiddleware + CsrfMiddleware.

Two-tier resource layout

  • Tier 1 (skill-shipped, read-only). Resolved via Composer\InstalledVersions::getInstallPath() to the plugin's skills/typst/ directory. Inter-Regular.otf, Inter-Bold.otf, and the starter invoice example ship here. Operators cannot delete tier-1 resources.

  • Tier 2 (principal, writable). Stored under <storage>/typst/<principal-id>/{fonts,templates,examples}/. Images live directly under <storage>/typst/<principal-id>/ so #image("basename.jpg") resolves against the same template_dir without an images/ prefix. Tier-2 wins on basename collision.

Listing returns the union (deduplicated by basename, tier-2 first). Reads consult tier-2 first, then tier-1.

Image library

The plugin also ships a per-principal image library — agents can upload PNG / JPEG / WebP / SVG and reference them in Typst source via #image("/api/v1/typst/images/<basename>"). Images live as plain files at <storage>/typst/<principal>/<basename> (not as media_assets rows) so the plugin's /api/v1/typst/images/{basename} URL stays stable across reinstalls.

Method Path Purpose
GET /api/v1/typst/images List images visible to the caller (principal-scoped)
POST /api/v1/typst/images Upload an image ({ filename, mime, content } — content is base64 or raw UTF-8 for SVG)
DELETE /api/v1/typst/images/{name} Delete an image by basename (404 if not found or owned by another principal)

Images are capped at TypstImageStore::MAX_BYTES (5 MiB) per upload and limited to the four MIMEs ext-typst can #image() natively. The controller uses the host's AssetStore to persist bytes, so storage mode (data_url vs local) follows the operator's existing configuration.

Playground compile

The /apps/typst admin panel ships a Playground tab where the operator can paste Typst source and click "Compile" to get a rendered PDF / PNG / SVG. The compile flow is exposed as:

Method Path Purpose
POST /api/v1/typst/compile Compile inline source to PDF/PNG/SVG ({ source, name, format, page, dpi })

The endpoint mirrors TypstCompileTool's action: "render" path: it materialises an inline text/x-typst parent row in the playground pool (so the natural-key on media_derivatives is well-defined), runs the same TypstRenderProducer, persists through MediaDerivativeService::create(), and returns the canonical asset URL.

The name field is optional for inline source — when omitted, the tool auto-generates a unique inline-YYYYMMDD-HHMMSS-XXXX.typ so the LLM doesn't need to invent a basename. The auto-name keeps the row findable in the playground picker. .typ is auto-appended when the supplied stem lacks it. For PDF it also produces a first-page PNG sibling so the UI can render an inline preview without a second round-trip.

The response shape:

{
  "data": {
    "derivative_id": "01HXYZ...",
    "asset_url": "/api/v1/assets/01HXYZ....pdf",
    "format": "pdf",
    "mime": "application/pdf",
    "size": 12345,
    "width": null,
    "height": null,
    "preview_url": "/api/v1/assets/01HABC....png"
  }
}

The HTTP controller payload above uses asset_url (singular). When the LLM-facing typst_compile tool surfaces the same result, the ToolResult.data envelope carries asset_urls (a list, kept plural even when one entry) — the canonical URL channel that downstream tool calls should read. See skills/typst/SKILL.md for the LLM-facing shape and the explicit "do not invent URLs" instruction that mirrors the OpenAI image plugin.

Permission model: POST /api/v1/typst/compile is scoped to the caller's user-principal (no ?principal_id=N). The derivative row inherits the parent principal automatically; PDFs and PNGs both end up in the caller's own media pool.

URL convention

The asset_url returned by both the compile endpoint and the image library is the canonical media-archive URL /api/v1/assets/<uuid>.<ext>. In Typst source, reference it directly via #image():

#image("/api/v1/assets/01HXYZ....png", width: 80%)

The production SPA serves these URLs from core's AssetController (no plugin-specific route is needed), so the same URL works inside Typst source, in chat markdown, and in the admin gallery.

Architectural distinction

Fonts and examples are plugin-private files (raw bytes, no media_assets row, no canonical asset URL). Images are full media_assets rows with a canonical /api/v1/assets/<uuid>.<ext> URL the chat UI can resolve. That difference is why fonts/examples use a basename-keyed storage path while images use the media_assets.id UUID as the addressable key.

Local development

In spora-local, add the plugin as a path repo to test it against the running skeleton:

// spora-local/composer.json
{
    "repositories": [
        { "type": "path", "url": "../spora-plugin-typst", "options": { "symlink": true } }
    ],
    "require": {
        "spora-ai/spora-plugin-typst": "@dev"
    }
}

Then composer update spora-ai/spora-plugin-typst. The plugin's register() hook adds the TypstRenderProducer to MediaDerivativeProducerDiscovery; the routes are picked up by the plugin loader automatically.

Layout

.
├── composer.json          # spora-ai/spora-plugin-typst + ext-typst + spora-core >=0.20.0
├── plugin.json            # manifest (class=FQCN, slug=typst, icon=file-type-2)
├── src/
│   ├── TypstPlugin.php                # entry point (register, routes, tools, apps)
│   ├── TypstApp.php                   # admin-app metadata (name=typst, entry=main.js)
│   ├── Exceptions/
│   │   ├── TypstCompilationException.php    # ext-typst compile failure (+ diagnostics)
│   │   ├── TypstInvalidArgumentException.php
│   │   └── TypstRuntimeException.php
│   ├── Http/
│   │   ├── AbstractTypstTextResourceController.php # shared CRUD for templates + examples
│   │   ├── TypstFontController.php             # GET/POST/DELETE /api/v1/typst/fonts
│   │   ├── TypstTemplateController.php         # GET/POST/DELETE /api/v1/typst/templates
│   │   ├── TypstExampleController.php          # GET/POST/DELETE /api/v1/typst/examples
│   │   ├── TypstImageController.php            # GET/POST/DELETE /api/v1/typst/images
│   │   ├── TypstCompileController.php          # POST /api/v1/typst/compile
│   │   ├── TypstPlaygroundSourceController.php # CRUD /api/v1/typst/sources
│   │   └── TypstDiagnosticFormatter.php        # sanitises compiler diagnostics for the playground
│   ├── Producers/
│   │   └── TypstRenderProducer.php    # MediaDerivativeProducerInterface impl
│   ├── Services/
│   │   ├── TypstResourcePaths.php     # tier-1 + tier-2 path resolution
│   │   ├── TypstResourceStore.php     # list/read/write/delete for tier-2 fonts/templates/examples
│   │   ├── TypstImageStore.php        # filesystem-backed image library
│   │   ├── TypstFilename.php          # shared basename validator (tool + controllers)
│   │   └── TypstWorldFactory.php      # builds Typst\World + Compiler + Inspector + per-call prelude
│   └── Tools/
│       ├── AbstractTypstTool.php      # shared source-resolution + visibility
│       ├── TypstCompileTool.php       # render / inspect
│       └── TypstResourcesTool.php     # fonts / templates / examples / images
├── skills/typst/
│   ├── SKILL.md                       # ~250 lines: workflow, syntax primer, limits
│   └── examples/invoice.typ           # starter template
├── agent-templates/
│   └── assistant.json                 # typst-assistant agent template
├── tests/
│   ├── Pest.php                       # shared bootstrap + DB migration
│   ├── bootstrap.php                  # BASE_PATH + autoload
│   ├── Pest.config.php
│   ├── Unit/
│   │   ├── Producers/TypstRenderProducerTest.php   # 9 cases, skips when ext-typst absent
│   │   └── Services/
│   │       ├── TypstResourcePathsTest.php          # 7 cases
│   │       ├── TypstResourceStoreTest.php          # 8 cases
│   │       └── TypstWorldFactoryTest.php          # 3 cases
│   └── Feature/
│       └── TypstDerivativeIntegrationTest.php     # 2 cases (register, end-to-end create)
└── .github/workflows/ci.yml           # pest + phpstan + cs-fixer

CI

Three parallel jobs (the standard Spora-plugin CI):

  • test — Pest (PHP 8.4 + 8.5 on ubuntu-latest).
  • static-analysis — PHPStan level 5 (memory limit 512M).
  • code-stylephp-cs-fixer dry-run.

Tests that depend on ext-typst skip themselves when the extension is unavailable, so the suite is green on vanilla ubuntu runners. The CI matrix can be extended to include a macos-14 leg that builds ext-typst from source — left as a follow-up to keep this PR focused.

Publishing

  1. git tag v0.1.0 && git push --tags.
  2. Configure Packagist to auto-pull from the GitHub repo.

The runtime reads the version from the git tag via Composer\InstalledVersions::getPrettyVersion().

Authoring guidelines

Framework-level conventions — which classes are plugin-stable, what's framework-internal, schema versioning, deprecation policy — live in the Spora docs → Plugin system. Route plugin logic through AgentOrchestrator and TaskService; do not import framework-internal driver classes.