Targetless Presentations¶
A Targetless Presentation lets an authenticated user generate visual content without owning or registering any inklet Display. The result is a versioned inklet Scene v1 JSON document and zero or more PNG renditions.
Key constraint: Generation never creates a Device row, publishes a Push, sends MQTT, or requires a compatible Display.
Authentication¶
Targetless Presentations are mounted on two prefixes backed by the same business logic:
| Prefix | Auth | Consumer |
|---|---|---|
/api/sdk/v1 |
PAT (Bearer il_pat_...) |
SDK, server integrations |
/api/app/v1 |
User access token (JWT) | macOS / iOS / Portal |
/api/app/v1 issues X-Renewed-Token for sliding renewal; /api/sdk/v1 never does.
Permissions¶
| Mode | Free | Pro |
|---|---|---|
mode=auto (AI generation) |
403 plan_upgrade_required |
Allowed |
mode=hardcode (direct image) |
Allowed | Allowed |
Downgraded users can still read existing Presentations, list them, renew URLs, and add rendition sizes to an existing Scene — none of these re-invoke AI or hit the Pro gate.
Creating Targetless Content¶
Add an output field to POST /contents. Its presence routes processing to the targetless branch instead of Display delivery.
POST /api/sdk/v1/contents
Idempotency-Key: <8-128 printable ASCII>
Content-Type: application/json
Authorization: Bearer il_pat_...
{
"mode": "auto",
"intent": "Create a calm, glanceable weekly summary",
"title": "Weekly Summary",
"output": {
"preset": "macos-widget-medium",
"formats": ["scene", "png"],
"colorMode": "color"
},
"assets": [
{ "type": "text", "text": "Revenue increased 12% this week." }
]
}
output fields¶
| Field | Type | Description |
|---|---|---|
preset |
string? | Named preset; mutually exclusive with viewport |
viewport |
object? | { "width": N, "height": N }; mutually exclusive with preset |
formats |
string[] | ["scene", "png"]; defaults to ["scene", "png"] |
colorMode |
string | color / grayscale / monochrome; default color |
If neither preset nor viewport is given, the default 800×480 preview profile is used. Viewport width and height are integers in 1...8192, and the total pixel count must not exceed 4,000,000.
Preset registry¶
| Preset | Viewport | Default colorMode |
|---|---|---|
default |
800×480 | color |
macos-widget-small |
170×170 | color |
macos-widget-medium |
360×170 | color |
macos-widget-large |
360×376 | color |
Per-mode rules¶
| Mode | displayId |
output |
Meaning |
|---|---|---|---|
auto |
Forbidden (null or omitted) | Required | AI generation, targetless |
hardcode |
Forbidden (null or omitted) | Required | Direct image, no AI |
manual |
Required | Forbidden | Display-bound, not targetless |
manual + output and output + displayId (non-null) both return 400 invalid_request and are additionally enforced by DB CHECK constraints.
Normalized Content output field¶
{
"output": {
"formats": ["scene", "png"],
"preset": "macos-widget-medium",
"viewport": { "width": 360, "height": 170 },
"colorMode": "color"
}
}
Legacy Display-bound Content returns "output": null.
Processing¶
Auto targetless¶
confirm → job → verify assets → fetch links → AI analysis →
build Scene v1 → persist Presentation + Content link →
Content marked ready → enqueue PNG renditions
Never enters Display routing, never creates a Push row, never sends MQTT. Scene generation may finish before PNG rendering; in that case the Presentation is preparing while the Content is already ready (Presentation ID is durable).
Hardcode targetless¶
confirm → job → build Scene (viewport fill, fit: "stretch") →
persist Presentation → enqueue PNG renditions
Accepts exactly one PNG or JPEG. Runs no AI analysis, summarization, or template selection.
Scene v1¶
Media type: application/vnd.inklet.scene+json;version=1
{
"scene": {
"mediaType": "application/vnd.inklet.scene+json;version=1",
"version": 1,
"data": {
"version": 1,
"viewport": { "width": 360, "height": 170 },
"background": "#ffffff",
"elements": [
{
"id": "headline",
"type": "text",
"frame": { "x": 20, "y": 20, "width": 320, "height": 80 },
"properties": {
"text": "Revenue up 12%",
"fontSize": 28,
"fontWeight": 600,
"color": "#000000",
"align": "leading"
}
}
]
}
}
}
Element types¶
text
| Property | Type | Default | Notes |
|---|---|---|---|
text |
string | — | Required |
fontSize |
number | 16 | pt |
fontWeight |
number | 400 | < 600 Regular; ≥ 600 Bold |
color |
string | #000000 |
Hex colour |
align |
string | leading |
leading / center / trailing |
Overflow is clipped and appended with an ellipsis. CJK code points use Noto CJK; other scripts use DejaVu.
image
| Property | Type | Default | Notes |
|---|---|---|---|
fileId |
string | — | Required; owned asset file ID |
fit |
string | contain |
contain / cover / fill / stretch |
API responses replace fileId with a 15-minute signed read URL (url field). The persisted Scene stores only fileId — no credentials or permanent signed URLs.
shape
| Property | Type | Default | Notes |
|---|---|---|---|
shapeType |
string | rect |
rect / ellipse |
fill |
string | #000000 |
Hex fill colour |
cornerRadius |
number | 0 | Pixels; rect only |
strokeColor |
string? | null | Hex stroke colour |
strokeWidth |
number | 0 | Pixels |
Common rules¶
- Frame coordinates are integers in the Scene viewport coordinate space.
widthandheightmust be positive.- Element
idvalues are unique within one Scene. - Stacking order = document order (later elements paint over earlier ones; no z-index).
- Out-of-bounds frames are clipped; alpha is composited onto a white background.
- Invalid values (out-of-enum, wrong type) degrade to the default; invalid structure (missing required field) returns
400. - Unknown optional properties are preserved by storage and ignored by renderers; unknown element types are skipped.
- Scenes must not contain PATs, user access tokens, presigned upload policies, private object keys, or permanent third-party credentials.
Presentation response model¶
{
"id": "019...",
"displayId": null,
"contentIds": ["019..."],
"mode": "auto",
"state": "ready",
"output": {
"formats": ["scene", "png"],
"preset": "macos-widget-medium",
"viewport": { "width": 360, "height": 170 },
"colorMode": "color"
},
"scene": {
"mediaType": "application/vnd.inklet.scene+json;version=1",
"version": 1,
"data": { "version": 1, "viewport": { "width": 360, "height": 170 }, "background": "#ffffff", "elements": [] }
},
"renditions": [
{
"id": "019...",
"mediaType": "image/png",
"format": "png",
"width": 360,
"height": 170,
"colorMode": "color",
"state": "ready",
"url": "https://...",
"expiresAt": "2026-09-01T20:15:00Z",
"failure": null,
"updatedAt": "2026-09-01T20:00:00Z"
}
],
"image": null,
"failure": null,
"createdAt": "2026-09-01T20:00:00Z",
"updatedAt": "2026-09-01T20:01:00Z"
}
Targetless vs Display Presentation differences¶
| Field | Targetless | Display |
|---|---|---|
displayId |
null |
UUID string |
scene |
Scene v1 object | null |
renditions |
list of renditions | [] |
image |
null |
rendered image object |
state |
preparing / ready / failed |
preparing / queued / published / confirmed / expired / failed |
Presentation aggregate state¶
Only the initial renditions (from Content output.formats) affect Presentation state:
| State | Meaning |
|---|---|
preparing |
Scene persisted; initial renditions not yet complete |
ready |
All initial renditions are ready or failed |
failed |
Scene generation itself failed (terminal) |
Renditions added later with POST /presentations/{id}/renditions never change the Presentation's aggregate state.
Rendition states¶
| state | url / expiresAt |
failure |
|---|---|---|
preparing |
null |
null |
ready |
Signed URL + expiry | null |
failed |
null |
structured problem |
- Signed URL lifetime is ~15 minutes; re-reading the Presentation re-signs the same stored file — no re-render.
- A
failedrendition remains queryable (not a404). - A single rendition failure does not affect the Presentation or any other rendition.
API endpoints¶
GET /presentations¶
GET /api/sdk/v1/presentations?scope=generated&state=ready&limit=20
GET /api/app/v1/presentations?scope=generated&state=ready&limit=20
| Parameter | Values | Default |
|---|---|---|
scope |
generated (targetless) / display / all |
generated |
state |
preparing / ready / failed |
all |
limit |
1–50 | 20 |
Returns the standard cursor page envelope { items, nextCursor, hasMore }.
GET /presentations/{id}¶
Pure read. May re-sign rendition URLs and Scene image asset URLs; never renders, publishes, enqueues, or wakes a Display.
- Presentation not yours →
404 presentation_not_found
POST /presentations/{id}/renditions¶
Append a new PNG size to an existing Presentation. Reuses the persisted Scene; never reruns AI.
POST /api/sdk/v1/presentations/{presentationId}/renditions
POST /api/app/v1/presentations/{presentationId}/renditions
Idempotency-Key: optional-but-recommended
Dedup key: (presentation_id, format, width, height, color_mode). Concurrent identical requests return the same rendition — no duplicate render work.
Response 202 (async):
{
"id": "019...",
"format": "png",
"width": 720,
"height": 340,
"colorMode": "color",
"state": "preparing",
"url": null,
"expiresAt": null,
"failure": null,
"updatedAt": "2026-09-01T20:05:00Z"
}
Poll GET /presentations/{id} for completion.
PNG rendering scale rules¶
| Case | Rule | Outcome |
|---|---|---|
| Requested size is same aspect ratio as Scene viewport | Uniform scale | Exact proportional scale, zero letterbox (covers Retina/HiDPI) |
| Requested size is different aspect ratio | min(sx, sy) scale, centred |
Scene background colour fills the letterbox; no distortion, no crop |
| Hardcode mode | Direct anisotropic stretch | Fills output exactly; no aspect-ratio preservation, no letterbox, no crop |
Same-ratio detection uses integer cross-multiplication (not float comparison) to prevent a spurious one-pixel border on Retina requests.
Resource limits¶
| Limit | v0.1 default | Env var | Over-limit code |
|---|---|---|---|
| Pixels per rendition | 4,000,000 | TARGETLESS_MAX_RENDITION_PIXELS |
400 invalid_request |
| Renditions per Presentation | 20 | TARGETLESS_MAX_RENDITIONS_PER_PRESENTATION |
409 invalid_state |
Concurrent preparing per user |
8 | TARGETLESS_MAX_CONCURRENT_RENDITIONS |
429 rate_limited + Retry-After |
- The pixel limit is enforced at both Content creation and
POST /renditions. - The concurrency limit does not apply to duplicate requests that hit the dedup key (rejecting them would break idempotency).
- A value of
0or unset falls back to the safe default — it is not treated as unlimited.
macOS app acceptance path¶
The macOS app uses /api/app/v1 with its existing access token:
POST /api/app/v1/contentswithoutput.preset=macos-widget-medium(or another preset)- Upload binary assets to the returned presigned tickets (no
Authorizationheader) POST /api/app/v1/contents/{id}/confirm- Poll
GET /api/app/v1/contents/{id}untilstate=readyorstate=failed - Retrieve
presentationIds[0] - Download the PNG rendition that best matches the current Widget family size
- Write the PNG and Scene JSON to the shared Widget App Group directory
- Reload the Widget timeline
The Widget itself never holds a PAT or access token and never relies on a signed URL staying valid. The host app owns network access and writes durable local cache files for WidgetKit.
Error reference¶
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_request |
Invalid output fields; pixel limit exceeded |
| 402 | payment_required |
Subscription billing failed |
| 403 | plan_upgrade_required |
Free user requesting mode=auto targetless |
| 404 | presentation_not_found |
Presentation not found or not yours |
| 409 | invalid_state |
Rendition count limit reached for this Presentation (permanent) |
| 429 | rate_limited |
Concurrent rendition limit; Retry-After header included |
Permission error codes
This implementation uses the existing entitlement codes plan_upgrade_required and payment_required rather than the original contract's subscription_required. Both semantics are equivalent; the existing codes were chosen to maintain compatibility with the public API and Portal.
TypeScript types¶
// ----- Output Profile -----
type ColorMode = "color" | "grayscale" | "monochrome";
type OutputFormat = "scene" | "png";
interface OutputProfile {
formats: OutputFormat[];
preset: string | null;
viewport: { width: number; height: number };
colorMode: ColorMode;
}
// ----- Scene v1 -----
type ElementType = "text" | "image" | "shape";
interface Frame { x: number; y: number; width: number; height: number; }
interface SceneElement {
id: string;
type: ElementType;
frame: Frame;
properties: Record<string, unknown>;
}
interface SceneData {
version: 1;
viewport: { width: number; height: number };
background: string;
elements: SceneElement[];
}
interface SceneV1 {
mediaType: "application/vnd.inklet.scene+json;version=1";
version: 1;
data: SceneData;
}
// ----- Rendition -----
type RenditionState = "preparing" | "ready" | "failed";
interface Rendition {
id: string;
mediaType: "image/png";
format: "png";
width: number;
height: number;
colorMode: ColorMode;
state: RenditionState;
url: string | null;
expiresAt: string | null;
failure: Problem | null;
updatedAt: string;
}
// ----- Targetless Presentation -----
type TargetlessPresState = "preparing" | "ready" | "failed";
interface TargetlessPresentation {
id: string;
displayId: null;
contentIds: string[];
mode: "auto" | "hardcode";
state: TargetlessPresState;
output: OutputProfile;
scene: SceneV1 | null;
renditions: Rendition[];
image: null;
failure: Problem | null;
createdAt: string;
updatedAt: string;
}
Explicitly deferred to v0.2¶
- Custom Display registration
- Device enrollment token and BYOD
- Capability reporting
- Quote/0 provider credentials and adapter
- Generic Display pull protocol
- Scene delivery / confirmation semantics
- Dynamic device transports