@vmlts/node
v0.1.1
Published
vmlts from Node and Bun: files, prompts and generations on one key
Readme
@vmlts/node
Files, prompts and pictures from Node or Bun, on the one key your app already holds. Read a prompt the operator changes without a deploy, say whether it worked, store a file behind a URL on your own domain, and generate an image or some words from the prompt you just read.
Day 0
One thing comes from whoever runs vmlts for you: an API key. It names your app, it is the only way in, and it reaches every tool your app opted into.
1. Install.
bun add @vmlts/node # or: npm i @vmlts/node2. Put the key in the environment.
export VMLTS_TOKEN=k_a3f9...3. Read a prompt.
import { PromptClient } from "@vmlts/node";
const prompter = new PromptClient();
const prompt = await prompter.use("support/greeting", { unit: userId });
prompt.content // the text, as it was served
prompt.render({ name: "Ada" }).text // with its placeholders filled
prompt.id // the answer a rating names4. Store a file.
import { FilestoreClient } from "@vmlts/node";
const files = new FilestoreClient({ slug: "blog", bucket: "photos" });
const { url } = await files.put("./holiday.mp4");
console.log(url); // https://cdn.vmlts.com/blog/photos/f7Kq2xBn9pWv/holiday.mp4The bucket is a namespace inside your app and has to exist already; make one in
the dashboard, or with createBucket, or let a write make its own with
ensureBucket.
5. Generate a picture.
import { GenClient } from "@vmlts/node";
const gen = new GenClient();
const { output } = await gen.image({
model: "bfl/flux-2-pro",
prompt: { key: "poster/hero", variables: { season: "winter" } },
size: "1024x1024",
});
console.log(output.files[0].url); // https://cdn.vmlts.com/blog/gen/gj_k3np9dq2rt.pngPrefer settings in code? Every client takes its settings directly, and anything you pass wins over the environment.
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 request until the operator moves
the split.
await prompter.use("support/greeting", { unit: userId });
await prompter.use("support/greeting", { version: 3 }); // or "v3"
await prompter.use("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.
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.
Saying whether it worked
A prompt has versions, and the dashboard shows which of them earns "good". Rate the answer you were served, from the object you already hold.
const prompt = await prompter.use("support/greeting", { unit: userId });
await prompt.feedback("good");
await prompt.feedback("bad", "it named the wrong product");prompt.feedback names the answer for you, so nothing in your code carries an
id around. When the prompt is off there is nothing to rate: the call sends
nothing and answers null, and you write no branch for it.
Rendered your own text, or read the prompt in another process? Name the version
yourself, and add requestId so a retry of your own request does not count the
rating twice.
await prompter.feedback({ useId, rating: "good" });
await prompter.feedback({ key: "support/greeting", version: 3, rating: "bad", requestId });
await prompter.feedback({
versionId,
rating: "good",
comment: "the caller replied in one message",
unit: userId,
variables: { name: "Ada" },
ran: { model: "openai/gpt-5", output: text },
});ran is what you ran the words through and what came back, so the dashboard
shows the answer beside the verdict.
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 rating carrying a
requestId is retried like any other call; one without it is sent once, because
a second attempt would be a second rating.
When the prompt is off
An operator can turn a prompt off, and a prompt can have nothing rolled out.
Either way use answers rather than throwing: you get the prompt's fallback
text with enabled: false, version: null and id: null.
const prompt = await prompter.use("support/greeting");
prompt.content // the fallback text. Always something to send.It throws for a key no prompt has, a key the service refused, and a transport
failure. Every failure is a VmltsError carrying code, status, message
and requestId — quote the requestId when you ask what happened.
Uploading
put takes what a server holds: a path on disk, bytes in memory, a Blob, or a
source you built yourself. A file past 64 MiB is split into parts and sent
several at a time, and the pool widens while a wider one still moves bytes
faster.
await files.put("./holiday.mp4"); // streamed off disk
await files.put(pdf, { key: "reports/2026-03.pdf" }); // a Uint8Array
await files.put(await answer.blob(), { key: "cat.jpg" });
await files.put("./holiday.mp4", {
onProgress: ({ loaded, total }) => bar(loaded / total),
signal: stopping.signal,
});With no key one is generated: a random prefix and the filename, so two people
uploading invoice.pdf do not overwrite each other. put returns once the
object is readable at the URL it hands back, and cancelling abandons the upload
rather than leaving parts behind.
ttl gives an object a lifetime in seconds, on the client or on one put.
ttl: 0 says forever out loud, overriding whatever default the bucket carries.
The rest of the bucket
for await (const object of files.list({ prefix: "2026/" })) console.log(object.key);
await files.listAll(); // the same objects, collected
await files.delete("cat.jpg");
await files.listBuckets();
await files.createBucket("invoices");
await files.deleteBucket("invoices"); // refused while it still holds filesKeys are relative to the bucket in both directions: it goes in front on the way
out and comes off what comes back. prefix is a second setting under it, for
the tenant, so a client holding one cannot reach a neighbour's objects.
Linking to a file
url builds the public URL for a key without asking the service, and takes the
read path's image options. signedUrl is the same URL with a signature on the
end, which opens a private object for that many seconds — hand one to a page,
and the page reads it like any other URL.
files.url("cat.jpg");
files.url("cat.jpg", { width: 400, fit: "cover", format: "webp" });
files.url("cat.jpg", { view: "thumb" }); // a named size the service expands
await files.signedUrl("2026-03.pdf", 60);Both need slug, the app your key belongs to, because it is the first segment
of every read URL. Signing needs keyId beside the token: the id of the key the
token is the secret of, off the same row in the dashboard.
Generating
image makes a picture and stores it; text answers words and stores nothing.
Both hold the connection while the provider works, so the answer you get back is
the finished thing: no handle to poll, no webhook to receive.
const picture = await gen.image({
model: "bfl/flux-2-pro",
prompt: "a necklace on white",
size: "1024x1024",
n: 2,
output_format: "webp",
});
const said = await gen.text({
model: "openai/gpt-5.1-mini",
messages: [{ role: "user", content: "say hello" }],
system: "be brief",
max_tokens: 64,
});Every answer names the model that made it, where the bytes went and where the
time went. The five phases of timings add up to total_ms exactly, so you can
show a breakdown without an "other" bucket.
picture.output.files[0].url // on your own domain, and it does not expire
picture.timings.generating_ms // the provider working
said.output.text
said.output.tool_calls // what the model asked you to rungen runs no tool. It hands the calls back whole, and you run them and send the
results as another ordinary request: the assistant turn with its tool_calls,
then a tool turn per tool_call_id. There is no session.
The prompt behind the picture
prompt takes the words, or the prompter prompt they live in. gen resolves it
itself, so the rollout is decided in one place and there is no second request.
await gen.image({
model: "bfl/flux-2-pro",
prompt: { key: "poster/hero", variables: { season: "winter" }, unit: userId },
});The same object goes wherever words go, content on a conversation turn
included. version, label, variables and unit mean what they mean on
PromptClient.use.
The answer names the wording it used, so you can rate it:
const made = await gen.image({ model, prompt: { key: "poster/hero" } });
made.prompt.text // the rendered words, exactly as the provider read them
await prompter.feedback({ useId: made.prompt.useId, rating: "good" });A conversation answers a list instead, one entry per turn that named a prompt,
each carrying the at of its turn. A request that wrote its own words answers no
prompt at all.
gen re-resolves nothing, so freeze prompt.text and send a plain string when the
next turn needs the same wording.
A prompt the operator switched off is refused as prompt_disabled, and never
generated from its fallback: prompter serves fallback text because text costs
nothing to send, and a picture of a fallback is billed.
Where a picture lands
The picture goes into filestore's namespace, under your app, and filestore's read path serves it.
https://cdn.vmlts.com / blog / renders / tenant-1 / posters/hero.png
app bucket prefix keyconst gen = new GenClient({ bucket: "renders", prefix: tenantId });
await gen.image({ model, prompt, key: "posters/hero.png" });All three are optional: the bucket defaults to gen and is made if your app has
none, and the key is minted from the generation's id and the type the provider
said the bytes are. A bucket or prefix on the client rides on image alone — a
conversation stores nothing, and a preset was stored with the placement its
operator chose — and whatever the call names beats it.
Which models you may reach
for (const model of await gen.models()) console.log(model.id, model.kind, model.limits);The roster is filtered by your key's policies, not refused against them: it is what this key may invoke, and an empty list is an answer rather than an error. The limits are what each model will take, so nothing here carries a copy of them.
Running a preset
A preset is a gen body an operator wrote once and named, so what your app generates can change without a deploy of your app. You post the values it declares and nothing else; the model, the prompt, the size and the placement stayed with them.
const made = await gen.custom("necklace", { style: "studio" });
if (made.kind === "image") console.log(made.output.files[0].url);Which kind it makes was decided when it was written, so branch on kind. The
dashboard is where the parameters and their descriptions are read.
When a generation fails
A generation is sent once. gen has already retried the provider behind the connection, so a retry from here is not a second try at a failed thing: it is a second generation, and may be a second charge for one picture. Ask for more only when you can afford the picture twice.
await gen.image({ model, prompt, attempts: 3 });Every failure is a VmltsError carrying code, status, message and
requestId — quote the requestId when you ask what happened. code is what
to branch on: provider_refused is the provider reading your ask and saying no,
provider_failed is it not answering at all, not_permitted is a model or a
bucket your key's policies do not allow, and daily_limit_reached is this key
out of generations for the day.
A mistake the request alone shows — a field neither shape has, a size that is
not a pair of edges, a prompt reference naming both a version and a label —
is refused before anything goes out, so a typo costs no round trip.
deadlineMs gives up on a generation, and defaults to 60 seconds: above gen's
own 50-second budget, so its answer wins the race. Cut it shorter and a picture
you are billed for becomes a failure you cannot look up, because you never
learned its id.
Settings
Each client takes its own, and all three read the key from VMLTS_TOKEN.
| PromptClient | Default | Environment variable |
|---|---|---|
| baseUrl | https://prompts.vmlts.com | VMLTS_PROMPT_URL |
| token | none; required unless the base URL forwards | VMLTS_TOKEN |
| unit | none; each call draws again | none |
| cacheTtl | 0 seconds, no cache across requests | none |
| attempts | 3 tries, the first one included | none |
baseUrl may be a path on your own origin that forwards to vmlts with the key
attached. A service that already routes its outbound calls through a gateway
holds the key in one place and passes none here.
A cacheTtl above 0 serves one answer out of memory for that many seconds,
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.
| FilestoreClient | Default | Environment variable |
|---|---|---|
| baseUrl | https://cdn.vmlts.com | VMLTS_FS_URL |
| token | none; required unless the base URL forwards | VMLTS_TOKEN |
| slug | none; url() fails without it | none |
| keyId | none; signedUrl() fails without it | VMLTS_FS_KEY_ID |
| bucket | none; keys then spell their own | VMLTS_FS_BUCKET |
| prefix | none; the tenant, under the bucket | VMLTS_FS_PREFIX |
| ensureBucket | off | VMLTS_FS_ENSURE_BUCKET |
| ttl | none; the bucket's default, or forever | VMLTS_FS_TTL |
| partSize | worked out from the file | VMLTS_FS_PART_SIZE |
| concurrency | measured while the upload runs | VMLTS_FS_CONCURRENCY |
slug has no environment name because it is the one setting the service never
sees: every write reads the app off the key, and only url needs to spell it.
| GenClient | Default | Environment variable |
|---|---|---|
| baseUrl | https://gen.vmlts.com | VMLTS_GEN_URL |
| token | none; required unless the base URL forwards | VMLTS_TOKEN |
| bucket | none; the picture lands in gen | none |
| prefix | none; the tenant, under the bucket | none |
| attempts | 1 for a generation, 3 for models() | none |
| deadlineMs | 60000, and 0 for none | none |
The bucket and the prefix take no environment name: where a picture lands is what a generation is for and the call names it, and the client setting is for an app that writes every picture into one place. The other two are behaviour rather than an address.
