@vmlts/react
v0.1.1
Published
vmlts from React: files, prompts and generations on one key
Readme
@vmlts/react
Read a prompt, upload a file and generate a picture from a React application, with one API key. The operator changes the words in the dashboard, and your application sees them without a deploy. React 18 and up.
Day 0
One thing comes from whoever runs vmlts for you: an API key. It names your app, and it stays on your server.
1. Install.
bun add @vmlts/react # or: npm i @vmlts/react2. Add one route to your backend, which forwards to prompter.
The browser never holds the API key, so the browser never talks to prompter directly. It talks to your own origin, and your backend attaches the key.
browser your backend prompt Worker
─────── ──────────── ─────────────
GET /api/vmlts/prompt/v1/resolve/support/greeting
├──── same origin ────────────► add x-api-key
│ no token ├── GET /v1/resolve/... ──► the prompt
◄────────────────────────────── ◄── {key, content, ...} ──Anything under /api/vmlts/prompt/v1/ forwards unchanged: same paths, same
query parameters.
// One route. Hono, Express and a Next.js route handler all shape the same.
app.all("/api/vmlts/prompt/v1/*", (c) => {
const url = new URL(c.req.url);
url.host = "prompts.vmlts.com";
url.pathname = url.pathname.replace("/api/vmlts/prompt", "");
return fetch(new Request(url, c.req.raw), {
headers: { ...c.req.header(), "x-api-key": process.env.VMLTS_TOKEN! },
});
});That route is also where you decide which prompts a visitor may read: the key is the leading path segment, so it is a string comparison.
Filestore takes the same route against cdn.vmlts.com, on a path of its own.
There the leading segments are the bucket and the tenant, so the same comparison
decides where a visitor may write and what they may name it.
gen takes it against gen.vmlts.com, and there the route is where you decide
who may spend a generation, on which model and into which bucket: the model and
the placement are named on the body's envelope, so each is a string comparison
too.
3. Wrap the tree once. baseUrl is the path on your own origin, not the
service URL. Each tool has a provider of its own, so an application nests the
providers for the tools it uses and reaches each with the one key.
import { FilestoreProvider, GenProvider, PromptProvider } from "@vmlts/react";
createRoot(document.getElementById("root")!).render(
<PromptProvider baseUrl="/api/vmlts/prompt">
<FilestoreProvider baseUrl="/api/files" bucket="photos">
<GenProvider baseUrl="/api/vmlts/gen">
<App />
</GenProvider>
</FilestoreProvider>
</PromptProvider>,
);4. Read a prompt.
import { usePrompt } from "@vmlts/react";
function Greeting({ userId }: { userId: string }) {
const { prompt, loading, error } = usePrompt("support/greeting", { unit: userId });
if (error) return <p>{error.code}</p>;
if (loading || !prompt) return <p>…</p>;
return <p>{prompt.render({ name: "Ada" }).text}</p>;
}Choosing a version
Without a selector the prompt's rollout decides, and unit keeps one caller on
one version: pass a user id, a session id, an account id — anything stable — and
that caller sees the same wording on every render until the operator moves the
split.
usePrompt("support/greeting", { unit: userId });
usePrompt("support/greeting", { version: 3 }); // or "v3"
usePrompt("support/greeting", { label: "concise" });A pin bypasses the rollout, so a unit beside it is ignored rather than
refused. Naming both a version and a label is refused before any request,
and reaches you as an error with the code selector_invalid.
Write the selector as an object literal if you like. The hook reads it field by
field, so a new object every render costs no request: only a changed key,
unit, version or label resolves again.
Filling the placeholders
A prompt declares the {{placeholders}} its text may use. render fills each
one with the value you passed, then the variable's default, then "" when the
variable is optional.
// "Hi {{name}}, you are on {{plan}}." name required, plan defaulting to "free"
prompt.render({ name: "Ada" }); // { text: "Hi Ada, you are on free.", missing: [] }
prompt.render({}); // { text: "Hi {{name}}, you are on free.", missing: ["name"] }A required variable with neither a value nor a default keeps its placeholder and
is named in missing. Hi , you are on free. reads like working text;
{{name}} does not.
Outside a component
The provider holds one client, and usePromptClient() hands it to you for a
call no hook covers — an event handler, a loader, a useEffect of your own.
const prompter = usePromptClient();
const prompt = await prompter.use("support/greeting", { unit: userId });new PromptClient({ baseUrl }) builds one without a tree around it.
When the prompt is off
An operator can turn a prompt off, and a prompt can have nothing rolled out.
Either way the hook hands you a prompt rather than a failure: the prompt's
fallback text, with enabled: false, version: null and id: null.
prompt.content; // the fallback text. Always something to render.error is set for a key no prompt has, a key the service refused, and a
transport failure. It is a VmltsError carrying code, status, message and
requestId — quote the requestId when you ask what happened.
code is why it was refused, in one word: prompt_not_found,
version_not_found, selector_invalid, unauthorized, forbidden,
rate_limited, internal, unavailable. Branch on it rather than on the
status — seven of them share a status with another — or on the sentence, which
can change. A code the service adds later still arrives whole.
requestId is the service's own name for the request that failed, and it is the
last attempt's: each retry is its own request with an id of its own. It travels
in a header the browser may not read, x-vmlts-request-id, so your forwarding
route either passes it back under access-control-expose-headers or leaves it
to the failure body, which carries the same id. Both reach this field.
Saying whether it worked
A prompt has versions, and the dashboard shows which of them earns "good". The prompt the hook handed you rates itself, and names the version for you.
function Greeting({ userId }: { userId: string }) {
const { prompt } = usePrompt("support/greeting", { unit: userId });
return (
<>
<p>{prompt?.content}</p>
<button onClick={() => prompt?.feedback("good")}>Good</button>
<button onClick={() => prompt?.feedback("bad", "it named the wrong product")}>Bad</button>
</>
);
}A rating is fire and forget. Nothing about it belongs in what you render, so unmounting the moment after the click sends the verdict anyway.
useFeedback is for a component that wants to show one going out, or say that
it was refused. rate never rejects — a click handler awaits nothing and has no
catch to write — so the refusal reaches you as error, a VmltsError with the
same code a resolve carries.
const { prompt } = usePrompt("support/greeting", { unit: userId });
const { rate, sending, error } = useFeedback(prompt);
<button onClick={() => rate("good")} disabled={sending}>Good</button>;
{error && <p>{error.code}</p>}A prompt the operator turned off has no row to rate: the rating sends nothing
and answers null, and you write no branch for it.
One answer takes one verdict: the same one again is the rating you already sent,
and a different one is refused as rating_conflict. A cacheTtl above 0 serves
one answer to everyone inside the window, so the second rating against it is
that conflict.
A component that rendered its own text holds no prompt. It names the version
instead, through the client the provider holds, and adds a requestId so a
retry of its own request does not count the rating twice.
const prompter = usePromptClient();
await prompter.feedback({ versionId, rating: "bad", requestId, ran: { model, output } });ran is what you ran the words through and what came back, so the dashboard
shows the answer beside the verdict.
Letting the browser deliver it
A verdict given as the page closes has nothing waiting for it, so hand the
delivery to the browser: beaconWire posts the rating with
navigator.sendBeacon, which outlives the page.
<PromptProvider baseUrl="/api/vmlts/prompt" wire={beaconWire}>It is opt-in because a beacon reports nothing back: feedback then answers
null rather than the row, and a service that refuses the rating cannot tell
you. Everything else still goes over fetch, and so does the rating on a
browser with no beacon and one that will not take the body.
Uploading a file
useUpload drives one upload and holds its state. upload takes a File off
an input, progress says how far it has got, result carries the stored object
once it lands, and cancel abandons it.
import { useUpload } from "@vmlts/react";
function Uploader() {
const { upload, cancel, state, progress, result, error } = useUpload();
return (
<>
<input
type="file"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void upload(file).catch(() => {});
}}
/>
{state === "uploading" && (
<>
<progress value={progress?.loaded ?? 0} max={progress?.total ?? 1} />
<button onClick={cancel}>stop</button>
</>
)}
{result && <img src={result.url} />}
{error && <p>{error.code}</p>}
</>
);
}The bytes are counted by the browser, not guessed: the SDK sends the upload
through XMLHttpRequest, which reports how far a request body has gone. Unmount
the component mid-upload and the upload is abandoned, and the parts already sent
are dropped.
A file with no key of its own gets one that keeps the filename, prefixed with
twelve random characters so two people uploading photo.jpg do not collide.
Name it yourself with upload(file, { key: "avatars/ada.jpg" }).
useObjects lists what is there. It follows every page, lists again when the
prefix changes and not when anything else does, and remove drops a key from
the service and from the list.
const { objects, loading, error, refresh, remove } = useObjects("holiday/");useFilestoreClient() hands you the client the provider holds, for a call no
hook covers: delete(key), and url(key, image?), which builds a read URL
without asking the service.
const files = useFilestoreClient();
files.url("ada.jpg", { view: "thumb" });
files.url("ada.jpg", { width: 400, height: 400, fit: "cover", format: "webp" });url needs the app slug, the leading segment of every read URL, which no
write ever spells. Every upload and every list already carries the URL back, so
url is for a key you hold and have not asked about.
There is no signed URL here. Minting a signature needs the API key, which the
browser must not hold, so the mint lives in @vmlts/node, on your server.
Generating a picture
useGeneration fires one generation and holds its state. A picture takes tens
of seconds, so state is what you render while it is made.
import { useGeneration } from "@vmlts/react";
function Poster({ season }: { season: string }) {
const { image, cancel, state, generation, error } = useGeneration();
return (
<>
<button
onClick={() =>
void image({
model: "openai/gpt-image-2",
prompt: { key: "poster/hero", variables: { season } },
size: "1024x1024",
}).catch(() => {})
}
disabled={state === "generating"}
>
Make it
</button>
{state === "generating" && <button onClick={cancel}>stop</button>}
{generation?.kind === "image" && <img src={generation.output.files[0]!.url} />}
{error && <p>{error.code}</p>}
</>
);
}Nothing generates on a render: a picture costs money, and a component that made
one while rendering would spend on every keystroke above it. image, text and
custom are what you call from an event handler, and each returns the answer as
well as putting it in generation.
The prompt is the words, or the key of a prompt prompter holds — the same key
usePrompt reads, resolved by gen at the moment of the generation.
generation.prompt then names the version that made this picture, and its
useId is what PromptClient.feedback rates.
generation is whichever kind the model made, so branch on kind before
reading output. text takes messages instead and stores nothing.
const { text } = useGeneration();
const said = await text({
model: "openai/gpt-5.1-mini",
messages: [{ role: "user", content: "say hello" }],
});
said.output.text;One hook drives one generation: firing again abandons the one in flight, and so does unmounting the component. Cancelling stops you waiting; it does not stop gen, which makes the picture and bills for it either way.
useModels() lists the models this key may invoke, with the limits of each. The
list is filtered by the key's policies, so an empty one is an answer and not a
failure.
const { models, loading, error } = useModels();useGenClient() hands you the client the provider holds, for a generation with
no component waiting on it.
Settings
There is no environment to read in a browser, so every setting is a prop on the tool's provider.
PromptProvider:
| Setting | Default | What it does |
|---|---|---|
| baseUrl | https://prompts.vmlts.com | the path on your own origin that forwards |
| token | none | refused here; the forwarding route holds it |
| unit | none; each call draws again | the unit every call sends unless it names its own |
| cacheTtl | 0 seconds, no cache | serve one answer out of memory for that many seconds |
| attempts | 3 tries, the first one included | a rating with no requestId takes one try whatever this says |
| wire | fetch | how a request goes out; beaconWire hands a rating to the browser |
A cacheTtl above 0 serves one answer out of memory, prompt.id included. Rate
what you resolved: two ratings against one id are one rating. enabled is a
kill switch, so nothing is cached by default.
FilestoreProvider:
| Setting | Default | What it does |
|---|---|---|
| baseUrl | https://cdn.vmlts.com | the path on your own origin that forwards |
| token | none | refused here; the forwarding route holds it |
| bucket | none; keys then spell their own | the bucket every key is relative to |
| prefix | none | the tenant, under that bucket |
| slug | none; url() fails without it | your app, the leading segment of every read URL |
| ttl | none; the bucket's own default, or forever | how long every object this client writes lives |
| ensureBucket | off | make the bucket on a write that finds it missing |
| partSize | measured from the file | bytes per part, and the size above which a file splits |
| concurrency | measured from the link | parts in flight at once |
An upload may name its own key, ttl, contentType, partSize,
concurrency and ensureBucket, and each beats the provider's.
GenProvider:
| Setting | Default | What it does |
|---|---|---|
| baseUrl | https://gen.vmlts.com | the path on your own origin that forwards |
| token | none | refused here; the forwarding route holds it |
| bucket | none; gen writes into gen | the bucket every picture lands in |
| prefix | none | the tenant, under that bucket |
| attempts | 3 tries for useModels; a generation takes 1 | a generation says so per call, and raising it buys a second picture |
| deadlineMs | 60000, and 0 for none | how long one generation may run |
A call may name its own bucket, prefix, key, attempts and deadlineMs,
and each beats the provider's. The bucket and the prefix ride on image alone:
a conversation stores nothing, and a preset was stored with the placement its
operator chose.
The forwarding route holds the connection for as long as the generation takes, so give it a timeout above the deadline. A gateway that gives up at 30 seconds turns every slow picture into a failure you cannot name.
Why the token never reaches the browser
/v1 sends no CORS headers and answers no preflight. A browser cannot reach it
on another origin, and a token would not help: a token in a browser is a token
every visitor holds, and it reaches every tool your app opted into.
So the SDK refuses a token beside a base URL that is not your own origin, and it
refuses it at construction rather than at the first request. Point baseUrl at
your own origin and keep the key on the server.
Under the bar
The same engine @vmlts/node runs. A prompt goes over fetch and an upload
over XMLHttpRequest.
| | Why |
|---|---|
| Each tool has a provider of its own, rather than one for both | An application that only reads prompts never names FilestoreProvider, so the upload engine leaves its bundle. |
| The provider holds the client in a useMemo over the settings themselves | A re-render above it would otherwise build a second client, and every request in flight would start over. |
| The hook's effect watches unit, version and label, not the selector object | A selector written inline is a new object on every render, and watching it would resolve the prompt on every render. |
| An upload sends through XMLHttpRequest, not fetch | fetch cannot say how far a request body has gone, so there is nothing for a progress bar to read. |
| Progress is held in a ref and written to state once a frame | Bytes arrive faster than a screen redraws, and a setState each time would render the page hundreds of times a second. |
| Unmounting cancels the request | The answer has nowhere to land, and a setState after unmount is a warning you did not earn. Mid-upload it also deletes the parts already sent, which nothing else knows about. |
| Unmounting does not cancel a rating | The verdict was given. Withdrawing it because the component went is losing what the operator asked for, and there is nothing half-sent to clean up. |
| A generation is one attempt, where every other read is three | gen holds the connection while the provider works and has already retried it behind that connection. A second request is a second picture, and may be a second charge. |
| A generation waits 60 seconds, where an upload waits on the transport | gen's own budget is 50 seconds, so a client that gave up first would throw away an answer it could have read, while the picture is made and billed anyway. |
| A file over 64 MiB goes up in parts, and the pool widens itself | The Worker exposes multipart as four independent requests and remembers nothing between them, so the split, the retry and the cleanup are the client's. No browser reports bandwidth, so the SDK measures the link by timing each generation of parts. |
| A 4xx other than 408, 425 and 429 is not retried | The service read the request and refused it, so an identical second one earns an identical refusal. A code narrows that and never widens it. |
