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)| Object | What it tracks |
|---|---|
| Content | Whether your assets reached storage. Nothing more. |
| Analysis | The agent run: reading, planning, rendering, delivering. |
| Presentation | One 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| State | Meaning |
|---|---|
pending | Assets are not all confirmed in storage yet. |
ready | Every asset is stored. |
failed | Ingestion 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| State | Meaning |
|---|---|
queued | Accepted and waiting for a worker. |
running | A worker holds it. |
completed | Finished. Read outcome. |
failed | Gave 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| State | Kind | Meaning |
|---|---|---|
preparing | both | The render is still being produced. |
ready | generated | The Scene and its renditions are durable. |
queued | display | Waiting for the display to ask for work. |
published | display | Handed to the display. |
confirmed | display | The display reported it is showing this frame. |
expired | display | Superseded, or replaced on the panel. |
failed | both | Rendering 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 oneKeys must be 8–128 printable ASCII characters with no spaces. Anything else
throws a ConfigurationError locally, and would be a 400 at the service.
| Situation | What happens |
|---|---|
| Same key, same body, finished | The original response is replayed verbatim. Nothing new is created. |
| Same key, same body, still in flight | 409 invalid_state — the first call has not answered yet. |
| Same key, different body | 409 idempotency_conflict. |
| Same key after a rejected request | A 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.