Custom views
Declared fields cover most funcs, but sometimes you want to own every pixel. A CUSTOM func
replaces fields.input / fields.output with a frontend you write yourself: a view.tsx
that renders the whole UI and calls your backend process() like a function.
File layout
my-func/
├── artifuncs.json # "type": "CUSTOM", "view": "view/view.tsx"
├── main.py # backend — process(input, settings) -> dict
├── requirements.txt # backend deps (pip)
└── view/
├── view.tsx # the whole frontend UI (entry point)
└── package.json # frontend deps (pnpm)
Everything frontend lives under view/; the root stays backend-only.
artifuncs.json
{
"name": "SHA-256",
"type": "CUSTOM",
"language": "PYTHON",
"view": "view/view.tsx"
}
No fields — the view owns the UI. settings still works and is readable from the view.
The view
A default-exported React-style component. Import from "react" (a light Preact runtime under
the hood — hooks work as usual) and "@artifuncs/sdk":
import { useState } from "react"
import { useArtifuncs } from "@artifuncs/sdk"
export default function View() {
const { run } = useArtifuncs()
const [text, setText] = useState("")
const [result, setResult] = useState<{ hash: string } | null>(null)
return (
<div className="grid gap-3 p-4">
<textarea className="rounded border border-rule bg-surface p-2 text-ink"
value={text} onChange={e => setText(e.target.value)} />
<button className="rounded bg-brand px-4 py-2 text-brand-ink"
onClick={async () => setResult(await run({ text }))}>
Hash it
</button>
{result && <code className="rounded bg-surface-2 p-2 font-mono text-ink">{result.hash}</code>}
</div>
)
}
The SDK — useArtifuncs()
| Member | Description |
|---|---|
run(input) | Calls process(input, settings) in the sandbox. Returns a Promise of the exact dict process() returned. Rejects with a typed error (e.type, e.message) when process() raises. Settings are attached automatically — run takes input only. |
settings | Settings API: settings.get(key, fallback?) (effective value: view-written > instance > declared default), settings.set(key, value) (visible to the next run(); persists on board instances), settings.all() (snapshot dict), settings.open() (opens the tool's settings panel). |
on(event, cb) | Subscribe to view events; returns an unsubscribe function. "settings.valueChanged" → cb(key, oldValue, newValue) fires when a setting changes (e.g. the user edits the settings panel — the view is NOT remounted; react in the handler). "theme.changed" → cb(theme) on light/dark switches. |
theme.get(token) | Resolved theme token value, e.g. theme.get("brand") → the brand color — for canvas / chart code that needs real values in JS. |
onLog(cb) | Live log stream: every print() in process() arrives as { level, message, timestamp }. Returns an unsubscribe function. |
files | Run-scoped file API — see below. |
Multiple backend operations? Branch on a key: run({ action: "hash", text }) and switch in
process().
Files — ctx.files
All file operations share the view's run scope, so uploads are visible to process() and
everything process() writes is fetchable back:
| Member | Description |
|---|---|
files.upload(file, { onProgress }?) | Upload a browser File eagerly. Resolves { name, size }; pass name in a later run() input. onProgress(loaded, total) for progress bars. |
files.text(name) | Read a file process() wrote, as a string. |
files.blob(name) | Read as a Blob. |
files.url(name) | Object URL — drop straight into <img src> / <a href>. Auto-revoked when the view unmounts. |
files.save(name, saveAs?) | Trigger a browser download. |
The shortcut: you usually don't need upload() at all — any File object you pass in a
run() input is uploaded automatically and replaced with its filename, so process()
receives the standard filename string:
const out = await run({ upload: file }) // file is a File from <af-file-field>
const imgSrc = await files.url(out.result.path) // show what process() generated
Styling
Tailwind utilities are compiled for your view automatically — no config, no install. Color, radius and font utilities are mapped to the app's theme tokens, so your view matches the app and follows light/dark mode with zero effort:
- Surfaces:
bg-bg,bg-surface,bg-surface-2,bg-surface-3 - Text:
text-ink,text-ink-2,text-ink-3,text-ink-4 - Brand:
bg-brand,text-brand-ink,bg-brand-soft,hover:bg-brand-hover - Borders:
border-rule,border-rule-2 - Semantics:
bg-info|success|warn|danger(+-softbackgrounds,text-*-inktext) - Radius:
rounded-sm,rounded,rounded-lg· Fonts:font-sans,font-mono
Standard Tailwind spacing/layout (p-4, gap-2, grid, flex, text-sm, …) all work. Raw
CSS with var(--surface) etc. works too.
For ready-made buttons, inputs, cards, alerts and badges, use the host's component classes — see the view components API.
Your view mounts edge-to-edge — there is no default padding around it. Field-based funcs
get a 16px content inset from the host; CUSTOM views don't, so you own every pixel of the
tile. Want breathing room? Add it yourself (e.g. a p-4 wrapper, like the starter template).
Want a full-bleed canvas, map, or editor? Just don't.
Frontend dependencies
Declare in view/package.json — installed with pnpm and bundled into your view on save:
{ "dependencies": { "chart.js": "^4.4.1" } }
React itself is provided by the platform — don't list it. Node-native packages can't run in
the browser; put that logic in main.py (with its deps in requirements.txt).
Workflow
- Scaffold:
artifuncs init my-func --custom - Web IDE: edit
view/view.tsx→ save → the preview rebuilds and remounts live. The preview header's Settings button (alsoctx.settings.open()) edits the settings declared inartifuncs.json— values feed executions and firesettings.valueChanged. - CLI:
artifuncs devsyncs your local folder; saving any file underview/triggers a sandbox rebuild and the open preview hot-reloads. - Build errors (syntax error in the view, missing import) appear in the log panel and the preview shows a build-failed state.