Developers
inklet Portal SDK
A server-side TypeScript client for the paper on your walls. Hand it text, a link, an image, or a PDF — inklet does the layout, picks the room, and renders for the panel. Follow the agent while it works, ask it about anything you have saved, or skip the AI and put a picture up as-is.
v0.4.2 · Node 22+ · ESM & CommonJS · MIT
import { Inklet } from "@inklethq/sdk";
const inklet = new Inklet({ pat: process.env.INKLET_PAT! });
const { analysis } = await inklet.push.auto({
title: "Daily brief",
intent: "Make the key update easy to scan",
assets: [
inklet.assets.text("Revenue is up 12% week over week."),
inklet.assets.link("https://example.com/report"),
],
});
const done = await inklet.analyses.wait(analysis);
console.log(done.presentationIds);Push
Three ways to put something on a wall.
They differ in how much of the decision you keep. Each is one call over an upload and an analysis, hands back the Content and the Analysis it started, and can be replayed safely under one idempotency key. Auto and Manual run inklet's agent and need a Pro plan; Hardcode runs no AI and stays on Free.
You have something worth showing and no opinion about where.
inklet reads the assets, chooses the displays that can render them, and does the typesetting. Intent is a sentence of direction, not a template — it steers the layout without describing it. Add context: "history" and the agent may also read what you sent before.
const result = await inklet.push.auto({
idempotencyKey: "daily-brief-2026-09-15",
title: "Daily brief",
intent: "Make the key update easy to scan",
context: "history", // optional: read earlier uploads too
assets: [
inklet.assets.text("Revenue is up 12% week over week."),
inklet.assets.link("https://example.com/report"),
],
});You know the room. inklet still sets the type.
One display, named by id. The agent still summarises and lays out — text, links, images, PDFs — but the routing decision stays yours.
await inklet.push.manual({
displayId: "display_123",
assets: [
inklet.assets.image({
data: await readFile("chart.png"),
filename: "chart.png",
contentType: "image/png",
}),
inklet.assets.text("This week's trend"),
],
});You already made the picture.
Exactly one PNG or JPEG, to exactly one display, rendered as sent. No AI runs and no AI allowance is spent. inklet scales it to the panel — your source does not have to arrive at 800×480.
await inklet.push.hardcode({
displayId: "display_123",
image: inklet.assets.image({
data: await readFile("poster.jpg"),
filename: "poster.jpg",
contentType: "image/jpeg",
}),
});Pipeline
A Content is what you sent. An Analysis is what inklet did with it.
Uploading and analyzing are separate steps. A Content is just the stored assets: no AI runs and nothing is spent until an Analysis references it. The Analysis produces Presentations, and a display shows one once it wakes and confirms it. Waiting returns when the run is over, or throws with the backend's code when it failed.
analyze() options
- contentIdsContents to analyze. Omit to summarise recent history instead.
- contextsubmitted, the default, or history: the agent may also read your earlier uploads.
- scope{ since: "72h" } — how far back history reaches. The plan clamps it; sinceAt says where it landed.
- targetOmit to let the agent choose, name displayIds to pin, or { output } for a software-only Scene.
- intent · titleA sentence of direction for the agent. title overrides the generated one.
// 1. Store Content. No AI runs, nothing is spent.
const { content } = await inklet.contents.upload({
title: "Dentist",
assets: [inklet.assets.text("Dentist at 9am tomorrow")],
});
// 2. Analyze it. inklet picks compatible Displays.
const analysis = await inklet.analyze({
contentIds: [content.id],
intent: "Make a reminder card",
});
// 3. Wait for the run. Runs that read your history
// queue one at a time, so raise timeoutMs for those.
const done = await inklet.analyses.wait(analysis);
if (done.outcome === "presentations") {
console.log(done.presentationIds);
} else {
console.log("no change:", done.noChangeReason);
}Events
Watch the agent work, as it works.
An Analysis publishes an ordered event stream: what the agent was given, what it is reading, what it planned, and how the result was rendered and delivered — or, for a question, the answer as it is written. It streams over server-sent events, resumes from the last event if the connection drops, falls back to polling behind a proxy that cannot stream, and ends on its own when the run does. Every event also reads as one plain English line, ready to put in front of a person.
- Acceptedanalysis.createdanalysis.dispatchedanalysis.leasedanalysis.lease_expired
- Workingcontext.materializedagent.activity
- Planningplan.submittedplan.rejectedplan.accepted
- Resultrender.finishedrender.faileddelivery.publisheddelivery.confirmeddelivery.failed
- Finishedanalysis.completedanalysis.failed
- Ask inkletassistant.deltaassistant.citationaction.card_createdaction.display_switched
A progress report, not the run's log. The agent's own working notes — each tool call, its arguments, the model's text — are not part of the public API at any depth, so a UI built on the stream survives a release that changes nothing you can see. The Result row lands after the run is already over, days later for a sleeping panel; read it back from the timeline.
import { describeEvent, isAnalysisEvent } from "@inklethq/sdk";
for await (const ev of inklet.analyses.watch(analysis.id)) {
console.log(describeEvent(ev));
// Reading your notes · 3 read
// Looked at 2 layouts · chose Daily Summary
// Submitted the plan · 2 actions
if (isAnalysisEvent(ev, "plan.accepted")) {
console.log(ev.data.presentationIds);
}
}
// Rendering and delivery are written after the run
// ends, so watch() never sees them. timeline() does.
for await (const ev of inklet.analyses.timeline(analysis.id)) {
if (ev.level !== "info") console.warn(ev.summary);
}Ask
A conversation with everything you saved.
A Conversation is a chat with the knowledge base. Every message starts one round: the agent searches and reads the notes, answers with the ones it drew on, and — only when asked to — starts a card or puts an earlier Presentation back on a display. Behind each answer is an ordinary Analysis with mode chat, so the same event stream applies, and chat rounds stay out of the history of cards unless you ask for them.
- reply()Sends, then follows the round until the answer is final
- send()Returns the reply's analysisId at once, for your own watch()
- retrieve()A Conversation with its last fifty Messages
- listMessages({ before })Pages further back
- rename() · delete()A manual title is never replaced by automatic naming
Asking is a Pro capability. A message is trimmed and refused before any request if it is blank or longer than 4,000 characters, and a second message while the previous round is still running is a conflict rather than a queue.
import { isAnalysisEvent } from "@inklethq/sdk";
const conversation = await inklet.conversations.create();
// One message, one round. The answer streams as it is written.
const { answer } = await inklet.conversations.reply(
conversation.id,
"When is my next dentist appointment?",
{ onDelta: (text) => process.stdout.write(text) },
);
console.log(answer.citations); // the notes it drew on
console.log(answer.actions); // what it did for you
// Or send, and follow the round yourself — what a relay does.
const { reply } = await inklet.conversations.send(
conversation.id,
{ text: "Put yesterday's card back on the kitchen panel." },
);
for await (const ev of inklet.analyses.watch(reply.analysisId)) {
if (isAnalysisEvent(ev, "assistant.delta")) {
process.stdout.write(ev.data.text);
}
}Control
Two things a push cannot do.
Switching what a panel shows runs no AI and uses neither your AI allowance nor your daily pushes. Generating runs the agent once, like any card, so it needs Pro — but rendering the result at another size never runs it again.
Switch the image on a display
Pick what is on the panel by hand. Any Presentation that has already been delivered there and rendered can go back up — including one that has expired — or you can simply move to the next queued one. Both land as pending until the panel confirms, and a panel's full history is one list call away.
// Everything this panel has shown before, newest first.
const shown = await inklet.presentations.list({
displayId,
state: "expired",
});
// Put an earlier card back. It confirms on its next sync.
const previous = shown.items[0].id;
await inklet.displays.setCurrent(displayId, previous);
await inklet.displays.waitUntilCurrent(displayId, previous);
// Or just move on to whatever is queued next.
const { changed } = await inklet.displays.advance(displayId);Generate a Presentation without a display
For software-only surfaces — a widget, a screensaver, a test. Generating is an upload plus an analysis with an output target: it registers nothing, queues nothing, and produces versioned Scene JSON with PNG renditions. Hand it a finished image instead and no AI runs at all. A stored Scene renders again at another size without a second AI run.
const generation = await inklet.presentations.generate({
intent: "Create a calm, glanceable summary",
assets: [
inklet.assets.text("Revenue increased 12% this week."),
],
output: {
preset: "macos-widget-medium",
formats: ["scene", "png"],
},
});
const presentation =
await inklet.presentations.waitUntilReady(generation);
console.log(presentation.scene?.data);
console.log(presentation.renditions[0]?.url);
// Another size from the same Scene. No second AI run.
await inklet.presentations.render(presentation.id, {
viewport: { width: 720, height: 340 },
});Reference
Every call, documented.
Browse the HTTP API referenceThe SDK is a thin, typed layer over the HTTP API — one method per endpoint, nothing hidden. Parameters, response shapes, error codes, and a TypeScript SDK snippet for every call live in the reference.
Returns the displays bound to the personal access token owner.
Query Parameters
cursor? string
Opaque cursor returned by the previous page.
limit? integer
Number of items to return.
Response Body
- 200application/json
- 400application/json
- 401application/json
- 429application/json
Displays
Contents
Analyses
All paths are under dev.iminklet.com/api/sdk/v1
Guardrails
A key that reaches your walls deserves care.
Server-only, by construction
Constructing the client where a document exists throws before a request is made. A personal access token cannot end up in a browser bundle by accident.
Uploads never carry the token
Binary assets go straight to temporary storage URLs. The token is sent only to inklet endpoints, and requests refuse absolute URLs and follow no redirects.
Safe to replay
Every Content and every Analysis is created under an idempotency key — yours, or one the SDK generates and hands back. Replaying a key returns the original resource, and the same key with a different body is a conflict. An error from such a call carries the key it used, so a retry is the same call, never a second one.
Errors you can act on
Every error extends InkletError and keeps the backend code, HTTP status, request ID, and structured details. The class comes from the status; the code and details are the stable parts. Messages are for logs — never branch on them.
Bounded in time
Every request has a timeout — 60 seconds, and five minutes for a storage upload — and every call takes a signal and a timeoutMs of its own. The waiting helpers and timeline() ride out up to three transient failures with back-off and honour Retry-After, and watch() reconnects where it left off. Calls that create or change something are never retried for you.
Open to what the backend adds
Fields the backend reports as a fixed set of words — a state, a mode, an activity's kind — are typed as the values this SDK knows plus any other string. A value it has never seen reads through, a waiting helper treats an unknown state as not finished, and nothing installed breaks.
import {
InkletError,
PermissionDeniedError,
RateLimitError,
} from "@inklethq/sdk";
try {
await inklet.analyze({ contentIds: [content.id] });
} catch (error) {
if (
error instanceof PermissionDeniedError &&
error.code === "plan_upgrade_required"
) {
// A Pro-only run on the Free plan. Upgrade, then retry.
} else if (error instanceof RateLimitError) {
// quota_exceeded clears at details.resetAt;
// rate_limited clears after error.retryAfterMs.
} else if (error instanceof InkletError) {
console.error(error.code, error.requestId, error.details);
}
}Choose where requests go
Requests go to dev.iminklet.com by default. baseUrl points the same code at another inklet service — a local or test instance today, and a Compute Hub on your own network once Hub ships, so nothing has to leave it.
10 MiB
per binary asset
50
assets per Content
20
event types, a closed set
Start with a token.
The SDK is pre-1.0. A minor release can change the API and lists every break in the changelog with what to change; a patch release never does, so the default caret range stays safe.
Create a token
Personal access tokens are issued under API tokens in the Portal, with a name and an optional expiry. Each is shown once — copy it into your environment, never into source.
Install the package
npm install @inklethq/sdk — Node 22 or newer, ESM or CommonJS, types included, MIT licensed.
Push something
One call puts words on a wall. Then watch the run, read a panel's history, or skip the agent and switch the image yourself.