yaks.app

Asking a model

← Documentation

The map is at The guide. This page is the whole of an app asking a model: the rows that ask, the rows that come back, and what it costs.

An app needs no key in its code and no server of its own to use a model. It writes a row that asks, its own store asks the model, and the answer is written back as rows. A connected model uses the app's integration; the key stays in the space's vault. A page subscribed to the transcript sees the answer land.

A transcript is rows

A transcript is any entity wearing session. Each line of it is an entry, and an entry that names a model with using asks for a turn:

let smith = '$smith'
await apply([
  { entity: { eid: smith }, doc: { title: 'The smith' }, session: {} },
  {
    entity: { eid: '$said' },
    entry: { session: smith },
    content: { body: 'A stranger walks up to the forge.' },
    using: {
      model: '@cf/zai-org/glm-5.3-flash',
      instructions: 'You are the village smith: gruff, kind, brief.',
    },
  },
])

using.model is any model name its selected provider serves, and using.instructions is what a conversational model is told before the transcript. Every entry that asks for a turn carries its own using, so each turn names its model and its instructions; an entry without one is part of the transcript the next turn reads, and asks for nothing.

Two more keep a turn cheap. using.window is how many of the transcript's newest lines the model reads, reaching back to the line that began the turn it cuts into; without it every turn reads the whole transcript, so a conversation that goes on for weeks costs more with every line. using.effort is how hard the model thinks before it answers: GLM Flash takes low, high or max (its default), and every word of that thinking is billed as output.

using: {
  model: '@cf/zai-org/glm-5.3-flash',
  instructions: 'You are the village smith: gruff, kind, brief.',
  window: 16,
  effort: 'low',
}

The store answers with more entries in the same transcript:

  • an ask — the moment the model was asked, with what the call used (usage)
  • the reply — content { body } with output { source } naming the ask

Watch for the reply the way a page watches anything:

subscribe(`.entry.session=${smith}&?content`, (rows) => draw(rows))

Nothing polls, and the write that asked is answered at once: the reply arrives through the subscription a moment later. Every new entry with using is another turn, and the model sees the whole transcript each time. session.status says where it stands: pending while a turn is owed, settled once the model has answered, failed when it could not be asked.

A question with a typed answer

Prose is for people. A decision a page acts on is a question with a typed answer, asked of Jev (typesafe/jev) by putting questions beside the entry:

await apply({
  entity: { eid: '$next' },
  entry: { session: smith },
  content: { body: 'Evening falls. The stranger has gone.' },
  using: { model: 'typesafe/jev' },
  questions: {
    asked: {
      plan: {
        type: 'choice',
        instructions: 'Where does the smith go next?',
        criteria: { forge: 'back to work', well: 'to rest by the well' },
      },
    },
  },
})

Each question comes back as an entry of its own wearing answer, named by the question:

answer { question: 'plan', choice: 'forge', confidence: 0.82,
         probabilities: { forge: 0.82, well: 0.18 } }

A choice picks one of its criteria's names. A noul answers a yes-or-no as the probability that it holds (answer.noul). A score places the answer along ordered criteria (answer.score). The page reads the newest one with .entry.session=<smith>&.answer.question=plan&.order=-entry.seq&.limit=1.

A model that answers no typed questions refuses them rather than guessing: questions are Jev's. The answers are for the page: no later turn reads them, or the questions another turn asked, so a transcript a page asks about every few minutes keeps its window for what was said.

Generate audio with a connected model

An app can ask bytedance-seed/seed-audio-1-0 for an audio clip. Its owner first calls store_connection_need for the app with integration: 'openrouter' and signs in on the space's connections page. The app never receives the OpenRouter key. The entry's text is the sound prompt; this model does not use using.instructions or typed questions.

await apply({
  entity: { eid: '$sound' },
  entry: { session: smith },
  content: { body: 'A forge hammer ringing on an anvil' },
  using: { model: 'bytedance-seed/seed-audio-1-0' },
})

The reply is an entry with attachment.artifact pointing to an artifact row. That row carries the media type and size. A page can play the bytes at ./api/blob/<artifact-id>; the platform keeps them in the app's blob store. If the app has no OpenRouter connection, the transcript receives a refusal with code: 'connection'.

Build from stored rows

A builder selects rows from this app's store with a query. Its content.body is the $var template; doc.body documents the builder. builder.to names a registered tool. The built-in model tool opens a session in the same store, and its answer becomes built rows:

import { modelToolEid } from '@yaks/builders/model'

await apply({
  entity: { eid: '$summaries' },
  doc: { title: 'Summaries' },
  content: { body: 'Summarize $body.' },
  using: { model: '<model-eid>' },
  builder: {
    query: '$note .note, doc.body=$body',
    to: modelToolEid(),
  },
})

Each outer query match gets a durable build and a fresh tool call when its key changes. The model returns named slots with components and the selected input ids each used. Each call keeps its own take in a slot; its citations are cites edges. Query .build.builder=<builder-id>&*, then .built.build=<build-id>&* to find the outputs; what was built for one row now is .built.build.build.for=<row-id>&.built.current=true&*. A vanished match leaves a stale build and its outputs as history. Changing builder.floor to a time in the past checks it again. A wake can check it later, and builder.immediate: true also checks changed inputs. Only app editors may write a builder, since model turns spend the owner's account budget.

A builder written with staged: {} builds nothing by itself, so a new prompt can be tried on a few rows first. store_builder_build builds it now: only names the rows to build for, limit takes the first few, and a template, provider or model tried in place of its own builds a shadow variant that nothing else selects. Remove the mark (staged: null) to build the rest; a row already built with the same prompt is not asked again.

One call, answered at once

A page that wants the answer in the same request, rather than in a transcript, posts ./api/ai/run the model's name and its input:

let res = await fetch('./api/ai/run', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    model: '@cf/baai/bge-base-en-v1.5',
    input: { text: ['a stranger at the forge'] },
  }),
})
let { data } = await res.json()

input is what Workers AI takes for that model (its page at https://developers.cloudflare.com/workers-ai/models/), and the answer is what the model says back. Nothing is kept: a transcript is the way to keep what was said. An app's worker asks the same way through a binding: "ai": { "binding": "AI" } in its wrangler.jsonc gives it env.AI.run(model, input) (see Code), which asks as the app itself.

To keep calls from the same conversation on the same cache-capable model worker, add a stable session_id beside model and input, or send the x-session-affinity header (the header takes precedence). An app's worker can use env.AI.run(model, input, {extraHeaders: {'x-session-affinity': session}}). Existing calls with only model and input keep working. Affinity is a routing hint, not a promise of a cache hit or support on every model. Transcripts send their session id automatically; keep instructions and tools stable where their meaning has not changed.

The same people may call it as may ask for a turn (below), and the same account budget pays. A refusal comes in the envelope every door answers with, {error: {code, message}}: 400 for a model not offered here or an input the model refuses, 429 past the budget, with the sentence that says so, and 503 while the model is busy. Show the person error.message.

Asking about a picture

A model that sees takes the picture inside the question, in the same one call: @cf/meta/llama-4-scout-17b-16e-instruct reads a message whose content is a list of parts, the words as a text part and the picture as an image_url part holding a data: URL. A transcript's entries are words only, so a picture is asked about this way.

Downscale the photo first, as for an upload (smaller() on Files and pictures); 1024 pixels on its long side is plenty for the model. Then send the same bytes to both places at once: upload keeps the picture in the app, and the call asks about it.

import { apply, upload } from './api/client.js'

let dataUrl = (blob) => new Promise((ok, no) => {
  let r = new FileReader()
  r.onload = () => ok(r.result)
  r.onerror = () => no(r.error)
  r.readAsDataURL(blob)
})

let file = input.files[0]
let photo = await smaller(file, 1024)
let question = 'What is in this photo?'
let [kept, res] = await Promise.all([
  upload(photo, { name: file.name }),
  fetch('./api/ai/run', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      model: '@cf/meta/llama-4-scout-17b-16e-instruct',
      input: {
        messages: [{ role: 'user', content: [
          { type: 'text', text: question },
          { type: 'image_url', image_url: { url: await dataUrl(photo) } },
        ] }],
        max_tokens: 400,
      },
    }),
  }),
])
let said = await res.json()
if (!res.ok) throw new Error(said.error.message)
await apply({ asked: { photo: kept.eid, question, answer: said.response } })

The answer's words are response. Nothing is kept by the call, so the last line keeps the question and the answer beside the picture's address, in asked, a component the app declares in its vocab.json as photo is declared on the files page.

The picture goes only as an image_url part. An image beside a prompt (the shape Llama 3.2 Vision takes) is ignored by this model, which then answers as if there were no picture. Llama 3.2 Vision itself is not offered here: its license has not been accepted for the platform.

Commands a model may call

A model may call the app's own commands (see Commands), but only the ones the app marks for it with "model": true. Those are the tools it is offered, and no others. A call runs as the person who asked for the turn, held to exactly what they could write on the page themselves, and $session in its template is the transcript the turn is in, which no argument can change. What it writes is stamped created.via with that transcript, which a page's own write never is: a row the app should trust only from its model says so.

Who may ask

A model's turn costs money, so by default only the space's members who may change the app ask for one: an entry with using from anybody else is refused like any other write they may not make, and so is their call to ./api/ai/run. An app that wants its visitors to ask too says so at the top of its vocab.json:

{ "models": "open", "$defs": { … } }

Then anyone who may write the app may ask, which on an open app is anyone with the link, and every turn they ask for spends the owner's budget.

The models, and what they cost

These platform models spend the owner's monthly yaks.app account budget:

modelforper million tokens inout
@cf/zai-org/glm-5.3-flashchat, and tools$0.15$0.50
@cf/meta/llama-4-scout-17b-16e-instructpictures$0.27$0.85
typesafe/jevtyped questions$0.042free
@cf/baai/bge-base-en-v1.5embeddings$0.067—

Every platform call is weighed by its model's price and spends that budget: $0.20, plus $3.00 for each Plus space they own. All their spaces share it. The builder and app voice spend it too. app_list says what each space spent.

bytedance-seed/seed-audio-1-0 uses the app's connected OpenRouter account. OpenRouter bills that account for generated audio at its published rate; it does not spend the yaks.app account budget.

A call that starts under the budget finishes even if it crosses it. The next one is refused: the transcript gets a refusal { code: 'limit' } entry whose content.body says which ceiling it met, and it rests there (failed) until a new entry asks again — on the 1st, or once the account's budget grows. A refusal row is the platform's, so a listing leaves it out unless the filter names refusal or asks for transcript entry rows. Subscribe with &?refusal to show the sentence.

A model's own rate is shared by every app on the platform. A turn that meets it is asked again; after three failures in a row the transcript rests as failed until a new entry asks.

Builder definition edits do not regenerate existing outputs. store_builder_build without redo flags builds only new bindings or changed inputs. outdated: true redoes selected bindings made under an older definition; rebuild: true redoes every selected binding, including unchanged ones. Both accept only and limit. Try an alternate template or model as a shadow first. .build.outdated=true finds older definitions; .built.current=true keeps selecting the chosen take until its binding disappears or another take is chosen. A successful redo adds takes, newest chosen by default; store_builder_choose chooses a retained earlier output without spending. List all takes with .built.build=<build>&*; .chosen shows the one in use.