inklet docs

Lifecycle

How a submission becomes a frame on the wall — Content, Analysis, and Presentation states, what to poll, what to watch, and how idempotency keys make a call safe to replay.

A push is a request, not a render. The call returns as soon as inklet has your assets; reading, planning, and rendering happen after — and a display only shows the result once it wakes and confirms it.

This page is the difference between "the call succeeded" and "it is on the wall".

Three objects, in order

Content  ──→  Analysis  ──→  Presentation  ──→  the panel
(storage)     (the work)     (the result)       (delivery)
ObjectWhat it tracks
ContentWhether your assets reached storage. Nothing more.
AnalysisThe agent run: reading, planning, rendering, delivering.
PresentationOne finished frame, and where it is in the delivery queue.

Uploading spends nothing. A Content sits in storage until an Analysis references it, which is the call that actually does work.

Content states

pending ──→ ready
        └─→ failed
StateMeaning
pendingAssets are not all confirmed in storage yet.
readyEvery asset is stored.
failedIngestion gave up; content.failure explains why.

Text and link Contents are ready immediately. Binary assets are pending until inklet has verified the uploads.

You usually do not need to wait for this. inklet.analyze() verifies pending uploads itself, so uploading and analyzing back to back is fine. A Content whose upload window closed fails with failure.code of upload_expired, and refreshUploadTickets() puts it back to pending.

Analysis states

queued ──→ running ──→ completed
                   └─→ failed
StateMeaning
queuedAccepted and waiting for a worker.
runningA worker holds it.
completedFinished. Read outcome.
failedGave up. analysis.failure explains why.

completed has two outcomes. presentations means presentationIds is populated; no_change means the agent looked and decided nothing was worth showing, with noChangeReason saying why. no_change is only reachable when the Analysis named no contentIds — name a Content and inklet either covers it or fails the Analysis with no_presentable_content.

queued can last a while. Analyses with context: "history" run one at a time per user, and scheduled ones queue behind them. A retried attempt does not leave running either: an analysis.lease_expired event is followed by another analysis.leased with attempt + 1.

Presentation states

The two kinds of Presentation use two vocabularies. A generated Presentation has no panel, so most of the delivery words do not apply to it.

generated:  preparing ──→ ready
                      └─→ failed

display:    preparing ──→ queued ──→ published ──→ confirmed
                                               └─→ expired
                                               └─→ failed
StateKindMeaning
preparingbothThe render is still being produced.
readygeneratedThe Scene and its renditions are durable.
queueddisplayWaiting for the display to ask for work.
publisheddisplayHanded to the display.
confirmeddisplayThe display reported it is showing this frame.
expireddisplaySuperseded, or replaced on the panel.
failedbothRendering or delivery failed; see failure.

A completed Analysis means its Presentation IDs are persisted, not that every frame is rendered and delivered. Rendering and delivery run after the Analysis reaches a terminal state. If you need the image bytes, read the Presentation too.

Waiting for it

Wait for the Analysis

const analysis = await inklet.analyze({ contentIds: [content.id] });

const done = await inklet.analyses.wait(analysis, { timeoutMs: 120_000 });

if (done.outcome === "no_change") {
  console.log("nothing worth showing:", done.noChangeReason);
} else {
  console.log("presentations:", done.presentationIds);
}

wait() throws AnalysisFailedError when the Analysis itself failed; the backend code is on error.details.backendCode. A timeout throws OperationTimeoutError and does not cancel the run.

Every waiting helper polls for you, treats its timeoutMs as a hard deadline, and rides out up to three transient failures in a row — a dropped connection, a timeout, or HTTP 408, 429, or 5xx — before giving up. See Timeouts and retries.

Wait for the frame, if you need it

for (const id of done.presentationIds) {
  const presentation = await inklet.presentations.retrieve(id, { format: "png" });
  const url = presentation.renditions[0]?.url ?? presentation.image?.url;

  if (url) console.log(url);
}

For a display-free Presentation, presentations.waitUntilReady(generation) does the waiting and returns the one Presentation.

Wait for the panel, if it matters

await inklet.displays.waitUntilCurrent(display.id, presentationId, {
  timeoutMs: 10 * 60_000,
});

A display shows a frame when it next wakes, which depends on its syncIntervalMinutes — so a confirmed state can be minutes away even when everything else succeeded immediately. Read display.nextSyncAt to know when to expect it.

Watching instead of polling

Every Analysis publishes an ordered event stream while it works, and watch() ends on its own once the run is terminal:

import { describeEvent } from "@inklethq/sdk";

for await (const event of inklet.analyses.watch(analysis.id)) {
  console.log(describeEvent(event));
}

The stream is a progress report, not the run's log: what the agent was given, what it is working on, what it planned, and how the result was rendered and delivered. Individual tool calls and model text are not part of it.

Idempotency

POST /contents and POST /analyses both require an Idempotency-Key header. The SDK generates one when you do not supply it, and returns the key it used:

const { content, idempotencyKey } = await inklet.contents.upload({ assets });
const result = await inklet.push.auto({ assets });

console.log(result.idempotencyKey); // "sdk-4f3c…"

push.* and presentations.generate() reuse one key for both the Content and the Analysis they start from it. The two endpoints keep separate key namespaces, so the same string means two different things on each.

To make your own retries safe, supply and reuse your own key:

const key = `daily-brief-${new Date().toISOString().slice(0, 10)}`;

await inklet.push.auto({ idempotencyKey: key, assets });
await inklet.push.auto({ idempotencyKey: key, assets }); // same push, not a second one

Keys must be 8–128 printable ASCII characters with no spaces. Anything else throws a ConfigurationError locally, and would be a 400 at the service.

SituationWhat happens
Same key, same body, finishedThe original response is replayed verbatim. Nothing new is created.
Same key, same body, still in flight409 invalid_state — the first call has not answered yet.
Same key, different body409 idempotency_conflict.
Same key after a rejected requestA fresh request. A 4xx releases the key rather than burning it.

Keys are remembered for 24 hours.

Retrying a call that failed

The SDK never retries a call that creates or changes something on its own. A call that fails after sending — a timeout, a dropped connection, a 5xx — may already have created something, so retry it with the key it used, not a fresh one. Every error such a call throws carries that key, including one the SDK generated:

import { NetworkError } from "@inklethq/sdk";

try {
  await inklet.push.auto({ assets });
} catch (error) {
  if (error instanceof NetworkError && error.idempotencyKey) {
    // Same input, same key: picks up what the first attempt created.
    await inklet.push.auto({ assets, idempotencyKey: error.idempotencyKey });
  } else {
    throw error;
  }
}

A generated key is only useful if you keep it. If you plan to retry, pass your own — derived from something stable in your domain, like a date, a record id, or a job id.

Uploads, retries, and missing assets

For binary assets, contents.upload() — and therefore push.* — does more than one round trip:

Create

The Content is created and the service returns one presigned upload ticket per binary asset.

Upload

Each asset is uploaded directly to its ticket URL, in parallel. Your token is not attached to these requests, and each upload is timed by the client's uploadTimeoutMs — five minutes by default.

Refresh once

If any upload fails, the SDK requests fresh tickets for exactly the failed assets and retries them once.

If assets are still missing after that, the SDK throws:

import { AssetUploadError } from "@inklethq/sdk";

try {
  await inklet.push.manual({ displayId, assets });
} catch (error) {
  if (error instanceof AssetUploadError) {
    console.error(error.contentId, error.failedAssetIndexes, error.cause);
  }
}

failedAssetIndexes maps back to the positions in the array you passed, so you can identify exactly which files did not make it; cause is the underlying failure of the first one. The error carries idempotencyKey too, so calling again with it picks the same Content back up.

On this page