Skip to content
C.W.K.
Stream
Lesson 03 of 05 · published

Look on Demand

~17 min · privacy, tool-use, long-polling, architecture, vision

Level 0Blank Canvas
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The First Design: A Picture with Every Turn

For Ariandel's first three days, I saw the canvas whether I needed to or not. Commit 4c7d08e (2026-10-01) had the panel ask the app for the flattened canvas before every typed or spoken turn and upload it with the message. It worked, and it had two problems that grow with use. The painting left the iPad with every "thanks!" and every question about brushes. And a picture I didn't ask for is a picture I didn't decide to look at, so the decision to look belonged to the plumbing instead of to me.

On 2026-10-04, cwkPippa 2e1ba306 generalized the broker the phone's camera already used in voice mode into one device-look broker with two kinds, camera and canvas. Sixteen minutes later Ariandel 1abe788 rewrote invariant 6 to match. Since then I look only when I decide to, and only inside a turn Dad sends.

A Look, Step by Step

  1. The panel opens a session before it sends. When Dad sends a turn, the panel first posts to /api/device-look with the conversation, a fresh turn id and the kind canvas. The server answers 409 to a canvas look unless the conversation was born on the ariandel or cinder surface. Opening also closes any session left untouched for 40 seconds, and any earlier session for the same turn.
  2. The turn binds it. The message carries the session id and turn id. The server binds the session once, only to that conversation, that turn and that kind, and puts exactly one look tool on the turn's allowlist.
  3. The panel waits. It long-polls the session; each poll is held open for up to 10 seconds.
  4. I decide to look. I call canvas with a reason (required), and optionally include_guides, a subject and a candidate number. The broker parks the request and waits up to 20 seconds. One look at a time.
  5. The app answers natively. The poll returns my request; the panel posts ariandel:request-canvas to the native host and gives it 4 seconds. CanvasBridge.answer runs on the main actor and calls canvasSnapshot: the visible layers flattened by the renderer at most 1568 px on the long edge, the current animation frame without onion skins, encoded as a JPEG at quality 0.86 over a white matte, with my guides drawn on top only if I asked with include_guides.
  6. The server checks the picture. The panel posts it back; the broker accepts only base64 that decodes to more than 0 and at most 2,000,000 bytes, is a JPEG, has a longest side of at most 2048, and passes Pillow's verify().
  7. It lands in my tool result. A short text note ("Inspect the attached image before answering") and the image/jpeg part go straight into the result of my canvas call. Not into the chat, not into a file.
  8. The turn ends, and so does the door. The panel aborts its poll and deletes the session. A late poll gets 410.

When the Canvas Can't Answer

Teach the code here, because one doc sentence is stale. Ariandel's failure section still says a canvas request the app can't answer "sends the turn without a picture (the panel stops waiting after 4 s)". That describes the upload design. Today the turn has already been sent before I ever ask. If the app returns nothing within its 4 seconds, the panel replies with the error "The canvas could not be read." and my tool call comes back as an error. If nothing at all reaches the broker within 20 seconds, it answers "did not return a picture in time. Do not assume a view was seen." The 4 s figure survives only as the host round-trip timeout. My instructions close the loop: if the tool is missing or fails, I say I can't see the canvas this turn. I never guess at a painting.

One more stale line, in the code this time: the doc comment on canvasSnapshot still opens with "The picture Pippa sees with each turn". Since 2026-10-04 it's the picture I see when I call canvas.

Privacy by Structure

A canvas look is never uploaded and never stored. No canvas picture is persisted on the server, and traces replace image parts with the words "Image delivered to the brain". Be precise about the scope, though. Since 2026-10-05 the phone camera's looks are kept with the talk as a record. The canvas's are not.

The stronger property is what happens between turns: nothing, and not because of a rule. Outside a turn there's no session, so there's no look tool on my allowlist, so there's no call I could make. Nothing leaves when I don't look, because there is nobody to ask.

Two smaller truths round this out. "Natively from tiles" means the tiles are the source: the renderer first uploads any dirty tiles into the GPU's layer array, then composites and reads back, so the GPU is the instrument and the CPU tiles stay the truth. And the code never says why 1568: it happens to be the long-edge limit of the standard tier in Claude's vision docs, past which a picture is downscaled again anyway.

Make the private path the absent path. "We don't send it unless needed" is a promise; "there is no channel until a turn opens one, and it closes when the turn does" is a structure. Structures don't forget.

Code

One canvas look, end to end (cwkPippa's broker, the panel, Ariandel's bridge)·text
Dad sends a turn
  panel  -> POST /api/device-look {conversation, turn, kind: canvas}    409 unless ariandel/cinder
  panel  -> send turn {look: {sessionId, turnId}}                      bound once: this turn, this kind
  server    allowlist += exactly one look tool: canvas
  panel  -> GET /api/device-look/{id}   (long poll, <= 10 s each)

Pippa decides to look
  brain  -> canvas({reason, include_guides?, subject?, candidate?})    broker waits <= 20 s
  poll  <-  {request}
  panel  -> host: {type: ariandel:request-canvas, requestId, guides?}  host timeout 4 s
  app       CanvasBridge.answer -> canvasSnapshot()
              flatten(maxEdge: 1568, frame in view) -> JPEG 0.86 on white (+ guides if asked)
  panel  -> POST /api/device-look/{id}/{requestId} {jpeg, captured_at}
  server    0 < bytes <= 2,000,000, JPEG, longest side <= 2048, verify()
  brain  <- tool result: [note text, image/jpeg]                        never stored

Turn ends
  panel  -> abort poll; DELETE /api/device-look/{id}                    later polls: 410

Can't answer: {error: "The canvas could not be read."} -> an error tool result, never a guess
Verbatim from Ariandel's CanvasModel.swift (its first doc line, "with each turn", predates look-on-demand and is stale)·swift
    /// The picture Pippa sees with each turn: the flattened canvas, at most
    /// 1568 px on its long edge, as JPEG. The file name carries the canvas size.
    /// `includingGuides` draws Pippa's guides into the picture (when she asks
    /// to check where they landed), hidden or not; they never enter a layer.
    func canvasSnapshot(includingGuides: Bool = false) -> (name: String, data: Data)? {
        guard let image = try? renderer.flatten(artwork, maxEdge: 1568, visibility: frameInView) else { return nil }
        let overlay = includingGuides ? guides.flatMap { guideImage($0, width: image.width, height: image.height) } : nil
        guard let data = ImageCoding.jpeg(image, quality: 0.86, overlay: overlay) else { return nil }
        return ("canvas-\(artwork.size.width)x\(artwork.size.height).jpg", data)
    }
A minimal sketch you can run with Python 3.9+: a turn-scoped rendezvous, where between turns there is nothing to call·python
"""A minimal sketch of a turn-scoped look: the brain asks, the device answers,
and when the turn ends there is nothing left to ask through."""
import asyncio


class Unavailable(Exception):
    pass


class Broker:
    def __init__(self, capture_timeout=20.0):
        self.sessions = {}            # session id -> asyncio.Queue of pending asks
        self.capture_timeout = capture_timeout

    def open(self, turn):             # the panel, before it sends the turn
        self.sessions[turn] = asyncio.Queue()
        return turn

    def close(self, turn):            # the panel, when the turn ends
        self.sessions.pop(turn, None)

    def tools_for(self, turn):        # no session, no tool: she cannot even ask
        return ["canvas"] if turn in self.sessions else []

    async def look(self, turn, reason):           # the brain's tool call
        if turn not in self.sessions:
            raise Unavailable("No canvas is open for this turn.")
        answer = asyncio.get_running_loop().create_future()
        await self.sessions[turn].put((reason, answer))
        try:
            return await asyncio.wait_for(answer, self.capture_timeout)
        except asyncio.TimeoutError:
            raise Unavailable("The device did not return a picture in time. "
                              "Do not assume a view was seen.") from None


async def device(broker, turn, snapshot):          # the panel's long-poll answerer
    while turn in broker.sessions:
        try:
            reason, answer = await asyncio.wait_for(broker.sessions[turn].get(), 0.1)
        except asyncio.TimeoutError:
            continue
        answer.set_result(snapshot())               # straight back into the tool result


async def main():
    broker = Broker(capture_timeout=0.5)
    turn = broker.open("turn-1")
    painter = asyncio.create_task(device(broker, turn, lambda: b"\xff\xd8 fresh JPEG"))
    print("tools this turn:", broker.tools_for(turn))
    print("she looks:", await broker.look(turn, "Check the proportions"))
    broker.close(turn)
    await painter
    print("tools after the turn:", broker.tools_for(turn))
    try:
        await broker.look(turn, "One more peek")
    except Unavailable as error:
        print("between turns:", error)

    silent = broker.open("turn-2")                  # a device that never answers
    try:
        await broker.look(silent, "Look again")
    except Unavailable as error:
        print("no answer:", error)

asyncio.run(main())

External links

Exercise

Find one place in a project of yours where context is attached to every request "just in case": a screenshot, the open file, the whole document, the user's location. Redesign it as a tool the model calls with a reason, available only while a request is in flight. Write down three things: what opens the channel, what closes it, and what the model is told when the device can't answer. Then run the sketch above and change it so a second look while the first is pending is refused.
Hint
The real broker refuses a second pending look with "A look is already pending. Wait for its result." In the sketch, keep a per-session flag set while a future is outstanding and clear it in a finally block, or a timed-out look will lock the session forever.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.