---
doc:
  title: Asking a model
guide:
  slug: models
  brief: an app's own store asks a model, and the answer lands as rows
  description: >-
    A transcript in the app's own store: a page writes an entry naming a model,
    the store asks it, and the answer lands beside the ask for any page
    subscribed to see. Instructions, typed questions answered as rows (a
    choice, a yes-or-no, a score), one call answered at once through
    ./api/ai/run or a worker's ai binding, asking about a picture, the
    commands a model may call, who may ask, and how platform and connected
    models are paid for.
---

# Asking a model

The map is at <https://yaks.app/docs.md>. 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](/docs/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](/docs/files)); 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](/docs/tools)), 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:

| model                                     | for             | per million tokens in | out   |
| ----------------------------------------- | --------------- | --------------------- | ----- |
| `@cf/zai-org/glm-5.3-flash`               | chat, and tools | $0.15                 | $0.50 |
| `@cf/meta/llama-4-scout-17b-16e-instruct` | pictures        | $0.27                 | $0.85 |
| `typesafe/jev`                            | typed questions | $0.042                | free  |
| `@cf/baai/bge-base-en-v1.5`               | embeddings      | $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.
