spora-ai / spora-plugin-typst
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
Requires
- php: ^8.4.1
- ext-typst: *
- spora-ai/spora-core: >=0.20.0 <2.0.0 || @dev
- spora-ai/spora-plugin-typst-frontend: >=0.1.0 <2.0.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- mockery/mockery: ^1.6
- pestphp/pest: ^5.0
- phpstan/phpstan: ^2.1
- phpstan/phpstan-mockery: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-08 19:05:50 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) andtypst_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
typstskill 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
TypstRenderProducerthat registers withMediaDerivativeProducerDiscoveryso any admin surface can dispatch into it. - A
TypstSourcePassthroughConverterregistered withMediaConverterDiscoveryso.typuploads are accepted (text/x-typstis 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:
-
Direct PHP integration with the derivatives core. The
typst_compiletool (action=render) callsMediaDerivativeService::create()directly via the DI container — no HTTP hop into a plugin-only controller. The derivatives surface in chat viaMediaEmbedmarkdown referencing the core's/api/v1/assets/<uuid>.<ext>route (served by core'sAssetController, not by any plugin). -
Independent admin UI. The
/apps/typstadmin panel handles Typst resources (fonts, examples) without crossing the Media Archive plugin's namespace. Operators can installspora-plugin-typststandalone.
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 whenext-typstis not loaded so CI on vanilla ubuntu runners can still run the rest of the suite. - Feature test under
tests/Feature/— end-to-end throughMediaDerivativeService::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 optionalfilename(the playground pool row name;.typis auto-appended). The parent row always lives intool_name='typst.playground', so it surfaces in the file picker; filename collisions create a sibling row rather than overwriting the existing source. Whenfilenameis 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.typmedia asset id) — renders the parent bytes. Re-rendering the same(file, format)pair refreshes the existing derivative row.filenameis ignored on this path.
fileandfilenameare distinct:filecarries a media-asset UUID,filenamecarries a free-form basename for inline source. LLMs frequently reach forfilewhen they meanfilename; 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'sskills/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 sametemplate_dirwithout animages/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-facingtypst_compiletool surfaces the same result, theToolResult.dataenvelope carriesasset_urls(a list, kept plural even when one entry) — the canonical URL channel that downstream tool calls should read. Seeskills/typst/SKILL.mdfor 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 onubuntu-latest).static-analysis— PHPStan level 5 (memory limit 512M).code-style—php-cs-fixerdry-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
git tag v0.1.0 && git push --tags.- 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.