@japan-ai-inc/studio-sdk
v0.7.0
Published
TypeScript SDK for Japan AI Studio platform APIs
Readme
@japan-ai-inc/studio-sdk
TypeScript SDK for the Japan AI Studio API. Provides typed, ergonomic access to Agents, Custom Objects, Storage, Workflows, and the Pages Rendering Service (PRS) platform.
Installation
Node.js 22 or later is required for the Node SDK and CLI.
npm install @japan-ai-inc/studio-sdkCLI (jai-studio)
Install the published CLI globally:
npm install --global @japan-ai-inc/studio-sdkInstall the coding-agent skill
The npm package includes a version-matched studio_sdk skill for Codex
and Claude Code. Installation is local: it does not require gh, a GitHub
account, or a network request.
# Interactive agent and scope selection
jai-studio skill install
# Non-interactive examples
jai-studio skill install --agent codex --scope project
jai-studio skill install --agent claude-code --scope user
jai-studio skill install --agent all --scope projectProject scope installs under .agents/skills/studio_sdk for Codex or
.claude/skills/studio_sdk for Claude Code. User scope installs under the
corresponding directory in the user's home. Re-running an identical install is
a no-op; a different existing copy is preserved unless --force is explicit.
Start a new coding-agent session after installation so the agent discovers the
skill.
Configure a profile
# Interactive prompt (recommended — key never appears in shell history)
jai-studio config set my-project
# Piped from a secret manager (CI/automation)
vault read -field=key secret/jai | jai-studio config set my-project --key-stdin
# Environment variable
JAI_STUDIO_API_KEY=pak_... jai-studio config set my-projectSecurity: Avoid passing API keys as CLI arguments — they are visible in shell history and process listings. Use the interactive prompt,
--key-stdin, or theJAI_STUDIO_API_KEYenvironment variable instead.
Profiles belong to the CLI. createClient does not read them, and
config show prints the stored key masked as <first 8>...<last 4>, so that
output cannot be piped into SDK code — the client rejects a masked key with an
explicit error instead of letting the server answer Invalid API key. Supply
JAI_STUDIO_API_KEY from your secret-management workflow for SDK code, and use
the CLI when a CLI command already covers the task.
Supported Usage Modes
This limited beta is primarily for developing Studio Apps locally. Production Studio App hosting runs through the Pages Rendering Service (PRS).
| Mode | Runtime | Authentication | Available modules | Supported use |
| ------------------ | ------------------------------ | -------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Direct development | Node.js 22 or later | Developer-owned Project API key | agents, members, objects, storage, workflows; Pages via CLI | Local development and explicitly controlled development automation. Arbitrary external production hosting or integration is not part of the beta support contract. |
| PRS browser | Browser inside PRS | PRS session; no API key | agents, objects, storage, platform | Browser code for a PRS-hosted Studio Page. |
| PRS server runtime | Node.js 22 or later inside PRS | Platform-managed Project API key injected into server-only runtime configuration | agents, members, objects, storage, workflows | Server code within PRS hosting. This is not an external integration mode. |
Only Node.js 22 or later is verified for the Node SDK and CLI. Browser support is limited to code running inside PRS.
In PRS browser mode, the Storage client preserves the public SDK shapes while
adapting requests to the PRS storage transport. Folder ZIP download is not
available in that mode; downloadFolder() remains available in direct and PRS
server-runtime modes.
Direct Development with a Project API Key
Use a developer-owned Project API key for local server-side development or explicitly controlled development automation. The API technically accepts valid Project API keys, but that capability does not create a supported arbitrary external production-hosting contract.
Project API keys are secret-equivalent. They must never enter browser bundles, client-side JavaScript, browser-public environment variables, logs, or source control.
import { createClient } from "@japan-ai-inc/studio-sdk";
const studio = createClient({
baseUrl: "https://api.japan-ai.co.jp",
apiKey: "pak_your_api_key", // Get this from Studio → Settings → API Keys
});
// All modules available: agents, members, objects, storage, workflows
const agents = await studio.agents.list();
const result = await studio.agents.invoke("agent-id", { prompt: "Hello" });Canonical Agent UUIDs are sent directly to /chat/v2; the SDK does not make a
separate Agent detail request to translate them to labels. Non-UUID identifiers
retain the legacy label-resolution path.
Available modules: agents, members, objects, storage, workflows
PRS Browser — No API Key
For browser code deployed as a Japan AI Studio Page, PRS provides the current session. Do not configure or read a Project API key in browser code.
import { createClient } from "@japan-ai-inc/studio-sdk";
// On PRS, baseUrl can be empty — fetch() uses relative paths.
// Auth is handled by PRS session cookies automatically.
const studio = createClient({ baseUrl: "" });
// agents.invoke() auto-detects PRS and routes through /api/agent-chat
const result = await studio.agents.invoke("550e8400-e29b-41d4-a716-446655440000", { prompt: "Hello" });
// Platform module — PRS-only features
const context = await studio.platform.waitForReady();
console.log(context.orgName, context.userName);
await studio.platform.openTask(data, "Review this");
await studio.platform.logout();Available modules: agents (auto-routes via PRS), objects, storage, platform
PRS Server Runtime — Platform-Managed Key
PRS may inject a platform-managed Project API key into server-only runtime configuration for a hosted app. Server code may use that value with the normal Node client. Never return it from a runtime-config endpoint or forward it to browser code.
import { createClient } from "@japan-ai-inc/studio-sdk";
const apiKey = process.env["JAI_PROJECT_API_KEY"];
if (!apiKey) {
throw new Error("Missing PRS server runtime API key");
}
const studio = createClient({
baseUrl: "https://api.japan-ai.co.jp",
apiKey,
});This key lifecycle is owned by PRS hosting. It is not a credential model for arbitrary external production deployment.
Available modules (PRS server runtime): agents, members, objects,
storage, workflows. The platform module is for PRS browser integration;
Pages deployment remains a development/control-plane operation rather than a
hosted app server-runtime capability.
Distributable Studio App Checklist
Before handing a Studio Page app to the publish/install workflow:
- Keep source-project resource IDs out of source code and browser assets.
- Put supported development-time Agent, Workflow, and Custom Object IDs in
root
.env*files under stable, descriptive server-side keys. - Never put Project API keys or source-project IDs in
NEXT_PUBLIC_*,VITE_*,REACT_APP_*, orPUBLIC_*values. - Keep storage configuration as logical paths or prefixes rather than storage UUIDs.
- Initialize browser SDK code with
createClient({ baseUrl: "" }); only server-runtime code may consume a platform-managed key. - Activate the SDK version only after a compatible PRS runtime is deployed.
How PRS detection works:
- The SDK checks for
window.JapanAI(injected by PRS at runtime) - When detected,
agents.invoke()routes through PRS's/api/agent-chatinstead of/chat/v2 - Other PRS-enabled modules (
objects,storage) go through PRS's/api/v1/*proxy; Storage translates the PRS transport contract back to the public SDK shapes - Supported calls require no code changes; see the Storage note for the PRS browser
downloadFolder()limitation
Limited-Beta Support
The beta audience is invited Studio App developers from existing Japan AI customers and partners. Contact your Japan AI account representative for access, support, or suspected security issues. Public package availability by itself does not expand support entitlement.
createClient(options)
| Option | Type | Required | Description |
| ----------- | -------------- | -------- | --------------------------------------------------------------------------------- |
| baseUrl | string | Yes | Studio API base URL (empty in PRS browser); with apiKey, HTTPS except loopback |
| apiKey | string | No | Project API key for server-side modes; never used in PRS browser |
| invokeUrl | string | No | Custom agent invoke URL; with apiKey, it must have the same origin as baseUrl |
| timeoutMs | number | No | Default timeout (default: 30s) |
| fetch | typeof fetch | No | Custom fetch implementation |
Returns a StudioClient with lazy-initialized module accessors: objects, agents, members, workflows, storage, platform.
Agents
Chat with AI agents, manage rooms, and retrieve message history.
List Agents
const { agents, totalCount, nextCursor } = await studio.agents.list({
limit: 10,
search: "support",
});Get Agent
const agent = await studio.agents.get("agent-id");
// agent.id, agent.name, agent.label, agent.description, agent.enabledInvoke (Non-Streaming)
const result = await studio.agents.invoke("550e8400-e29b-41d4-a716-446655440000", {
prompt: "Summarize this document",
sessionId: "room-id", // optional — continues conversation
model: "gpt-4o", // optional
temperature: 0.7, // optional
systemPrompt: "...", // optional
isEphemeral: true, // optional — don't persist to history
});
console.log(result.text); // AI response
console.log(result.sessionId); // Room ID for follow-ups
console.log(result.status); // "succeeded" | "failed"
console.log(result.references); // Source references (if any)References use the backend-native grouped or detailed shape. Both include
artifactId and filename; grouped references may include pageNumbers,
sheetNames, and sourceUrl, while detailed references add index and may
include a single pageNumber or sheetName.
Long-running handoff requires a persistent room: pass its roomId as
sessionId, set isEphemeral: false and enableHandoffLongRunJob: true, then
poll room messages until the background result appears. The SDK rejects
ephemeral handoff requests.
Invoke (Streaming)
for await (const event of studio.agents.invokeStream("550e8400-e29b-41d4-a716-446655440000", { prompt: "Write a haiku" })) {
if (event.type === "delta") {
process.stdout.write(event.content); // Stream text chunks
}
if (event.type === "error") {
console.error(event.text);
}
}Rooms & Message History
// Create a new chat room
const room = await studio.agents.createRoom("agent-id");
console.log(room.roomId);
// Get messages in a room
const { messages, nextCursor } = await studio.agents.getMessages("room-id", {
limit: 20,
cursor: "next-page-cursor",
});
// messages[0].id, .role, .content, .createdAtRoom reads are project-scoped, and a PRIVATE room is readable only by its owner. A Project API key acts as its creator, so a key shared by a multi-user app gives every caller the creator's visibility; per-user privacy is only available on the PRS page-session path.
Canvas Documents
Agents with canvas tools (html_slide decks, text and code canvases) save their output as canvas documents of the room and return only an id on the chat stream. Read them by room:
// List the room's canvas documents (metadata only, oldest first)
const { canvasArtifacts, nextCursor } = await studio.agents.listCanvasArtifacts("room-id", { limit: 20 });
const deck = canvasArtifacts.find(a => a.documentType === "hslides");
if (!deck) throw new Error("No deck in this room");
// Read one with its stored content and compare-and-set token
const artifact = await studio.agents.getCanvasArtifact("room-id", deck.id);
// artifact.content = { document_type, ...tool metadata, text }
// For html_slide decks, text is a JSON string holding the deck manifest:
const manifest = JSON.parse(artifact.content.text as string);
// manifest.slide_count, manifest.slides[].html, manifest.assets[]
// artifact.contentHash — md5 of the stored content, for change detectionroom:// references inside slides[].html and assets[] entries (URI key
uri, source_uri or path, depending on the last writer) are returned
unresolved. Canvases created from files uploaded to the room are listed too.
Requires a Public API deployment that serves the canvas read routes; older
deployments return 404.
Write and Publish a Deck (PRS Browser)
A Studio Page can write an hslides deck into a room canvas and open it to anyone with its link:
// Choose the id yourself and keep it: if a write's outcome is unknown, repeat it with the same id
const canvasId = crypto.randomUUID();
await studio.agents.putCanvasArtifact(room.roomId, canvasId, {
name: "提案書",
content: { document_type: "hslides", text: JSON.stringify(manifest) },
});
const shared = await studio.agents.publishCanvasArtifact(room.roomId, canvasId, "ORGANIZATION");
// shared.url opens for the organization's members once they sign in
const published = await studio.agents.publishCanvasArtifact(room.roomId, canvasId, "PUBLIC");
// published.url opens without signing in
await studio.agents.unpublishCanvasArtifact(room.roomId, canvasId);
// back to the room's share scope- Only the signed-in user of a Page session who may write the room, in a
room created through the Public API (
createRoom()); a room made in the chat app is not found (404). API keys are refused (403). - A new id creates the canvas with the room's share scope; an existing one
has its name and text replaced; an equal repeat changes nothing. Only
hslides canvases (409
NOT_A_DECK). content.textis at most 8 MiB in UTF-8 (413DECK_TOO_LARGE). Inline pictures as data URLs:room://references are refused (400ROOM_ASSET_REFERENCE), on publishing too.- Publishing clears a password set in the chat app.
PUBLICneeds the organization to allow public sharing (403PUBLIC_SHARING_DISABLED);ORGANIZATIONdoes not, and is refused while the room itself is readable without a session (409ROOM_IS_PUBLIC), as stopping is; a new canvas in such a room is refused the same way. The scope is part of the route: a deployment without it answers 404 and changes nothing. - Every canvas summary, from the reads too, carries
shareScope,roomShareScope,passwordProtectedandurl(null for a canvas without an organization or when the backend has no app origin). They describe the chat app's viewer link; they are not access control on this API. - Requires a Public API deployment that serves these routes; older deployments return 404 and omit the four fields.
Compare Models on One Turn (PRS Browser)
compare() asks 2–4 models for one answer each to the same user turn. The
room shows one answer (the primary model's, or the lowest successful one if
it fails) and keeps the others as candidates the user can switch to. Every
model is billed.
let roomId: string | undefined;
let turnId = "";
const texts: string[] = [];
for await (const event of studio.agents.compare("550e8400-e29b-41d4-a716-446655440000", {
prompt: "Summarise this plan",
models: ["claude-opus-5-5", "gpt-6-sol"],
primary: "claude-opus-5-5", // default: models[0]
sessionId: roomId, // omit to start a new room
})) {
switch (event.type) {
case "candidates_started":
({ roomId, turnId } = event); // event.candidates[i].model
break;
case "delta":
texts[event.candidateIndex] = (texts[event.candidateIndex] ?? "") + event.text;
break;
case "candidate_reset":
texts[event.candidateIndex] = ""; // narration before a tool call, not the answer
break;
case "tool":
// event.name, event.status: "running" | "done" (agents with read-only tools)
break;
case "candidate_done":
// event.status: "ok" | "error" | "timeout" | "refused" | "stopped" | "pending"
// event.metrics: { ttftMs, latencyMs, outputTokens }
break;
case "turn_done":
// event.adoptedIndex undefined = not known yet; re-read with getTurns()
break;
case "error":
console.error(event.code, event.text);
}
}
// Show another candidate as the room's answer (room owner, last turn only)
await studio.agents.adoptCandidate(roomId!, turnId, 1);
// Read the room's turns with their candidates, newest page first
let before: number | undefined;
do {
const page = await studio.agents.getTurns(roomId!, { limit: 20, before });
// page.turns[i]: { turnId, question, answer, candidates[{ index, model, status, isAdopted, text, tools, ... }] }
before = page.nextBefore ?? undefined;
} while (before !== undefined);- When the agent lets compare turns use read-only tools, each candidate also
yields
toolevents and may yieldcandidate_reset. A reset drops the candidate's streamed text only, not its tool lines. A tool stillrunningwhen its candidate'scandidate_donearrives is finished.toolCallIdcan repeat across rounds: adonecloses the latestrunningcall with that id, so do not dedupe tool lines by id. - Available only in PRS browser mode. Outside PRS the three methods throw
TurnCandidatesApiError; the Public API has no turn-candidates route yet. - Rooms are the signed-in viewer's own rooms in the page's project. Another
project's room is reported as
room_not_found. adoptCandidate()andgetTurns()need a page viewed by a signed-in user. On anANONYMOUSor service-account page they are refused with 403 "A signed-in user is required".- Take the turn id from
candidates_started.turnId. Do not derive it from a request id you send: in production the ingress replacesX-Request-ID. - A failed candidate carries
errorCode(timeout,refused,empty_answer,context_too_long,unsupported_input,provider_unavailable,provider_rejected,internal) and a fixed Englisherrormessage. Show your own text per code; provider error details are never returned. - Match a candidate across the stream and
getTurns()by its index (candidateIndex/index), not by model name: the stream reports the requested model (plusservedModel), whilegetTurns()reports the model actually called, e.g.claude-sonnet-latest→claude-sonnet-5. - A missing
adoptedIndexonturn_donemeans unknown, not "no candidates": the candidate write can land after the stream ends. CallgetTurns()before offering an adopt. compare()throwsTurnCandidatesApiError(anAgentsApiError) when the request is refused before streaming starts;codeis the agent-service code when PRS relays one (such asinvalid_candidates), elseBAD_REQUEST. A refusal by the agent service after PRS opens the stream, such as a model the Agent does not allow or a busy room, arrives as anerrorevent with the samecode.adoptCandidate()andgetTurns()refusals throwTurnCandidatesApiErrorwithcode:BAD_REQUESTfor PRS-local checks, otherwise the agent-service code, such asnot_last_turn,generation_in_progress,room_busy,memory_compacted,room_frozen,turn_deleted,candidate_not_ok,candidate_not_found,turn_not_found,room_not_found,not_enabled,forbiddenoradopt_failed.invokeStream()is unchanged.
What Turns and Rooms Cost (PRS Browser)
In a Page, invoke() results and invokeStream() deltas carry requestId:
the turn's request id in the credit ledger. Keep the ids an action makes, then
read what they cost, or read what a whole room has cost:
const ids: string[] = [];
for await (const event of studio.agents.invokeStream(agentId, { prompt })) {
if (event.type === "delta" && event.requestId && !ids.includes(event.requestId)) {
ids.push(event.requestId);
}
}
// Each larger total as the ledger fills (read 5 s, 30 s, 2 min, 5 min after).
for await (const credits of studio.agents.followTurnCredits(ids)) {
showCost(credits);
}
// Once: the turns' total so far, and a room's running total.
const { credits } = await studio.agents.getTurnCredits(ids);
const room = await studio.agents.getRoomCredits(roomId);- Turn reads count only the signed-in user's own turns in this project from the last 24 hours, 1-200 ids per call: the turns' own model and tool calls and the workflows they called. Keep ids per action, not for the page's lifetime.
- A room's total counts every member's turns, the workflows they called and background coworkers, and is readable by whoever may read the room. It needs no ids, so it survives a reload.
- The ledger holds a turn seconds after it ends (minutes on a retry): a read
right after it may be
0.followTurnCredits()andfollowRoomCredits()read again, yield only larger totals, and end quietly where the cost cannot be read here (403, 404) or whensignalaborts. Following no ids yields nothing. - These calls need a page viewed by a signed-in user; anywhere else they throw
AgentsApiError.
PRS Behavior
When running inside the Pages Rendering Service (PRS), invoke() and
invokeStream() automatically route through /api/agent-chat instead of
/chat/v2. The SDK detects PRS via window.JapanAI and classifies the first
argument after trimming it:
- A canonical 8-4-4-4-12 hexadecimal UUID (case-insensitive, with no
version/variant restriction) is normalized to lowercase and sent as
agent_id. PRS resolves and authorizes that ID from the authenticated Page's release-managed runtime metadata. An ID not declared by that Page is rejected; it does not fall back to label routing. - Any other non-empty string is sent as the legacy
agent_label. For example,"agent-id"is a label, not a stable Agent ID. - An empty or whitespace-only string sends neither field, allowing PRS to use its configured fallback Agent.
SDK-generated requests never contain both identity fields. A legacy Agent whose
label is itself a canonical UUID must use the low-level
studio.platform.chat({ agentLabel }) API to force label routing; that path does
not use release-managed Agent ID resolution or its version/runtime pinning.
Outside PRS, the existing Agent lookup and invocation path remains unchanged.
The compatible PRS agent_id contract must be deployed before an SDK version
using UUID routing is released or activated. An older PRS may ignore
agent_id and invoke its fallback Agent instead.
In PRS, storageFileIds attaches Studio Storage files by fileId instead of
sending their bytes from the browser. PRS checks each file with the Page user's
storage permissions and the Agent reads the stored file itself, so replacing or
deleting it later changes what later turns of the room see. A call takes at
most 20 IDs, and it can be combined with files. Outside PRS the SDK rejects
storageFileIds.
const { files } = await studio.storage.upload(images, { path: "/inputs" });
const result = await studio.agents.invoke(agentId, {
prompt: "Describe these slides",
storageFileIds: files.map(file => file.fileId),
});Objects
Manage custom object schemas and their records.
Object Definitions
// List all objects
const { data } = await studio.objects.list();
// Create an object
const obj = await studio.objects.create({ objectName: "contacts" });
// Get / Update / Delete
const obj = await studio.objects.get("objectId");
await studio.objects.update("objectId", { displayName: "Contacts" });
await studio.objects.delete("objectId");Records (via define<T>())
define<T>() returns a typed handle for record operations on a specific object.
type Contact = { name: string; email: string; company?: string };
const contacts = studio.objects.define<Contact>("contacts");
// CRUD
const record = await contacts.create({ name: "Acme", email: "[email protected]" });
const fetched = await contacts.get("record-id");
const updated = await contacts.update("record-id", {
name: "Acme",
email: "[email protected]",
company: "Acme Inc",
});
await contacts.delete("record-id");
// List with pagination
const page = await contacts.list({ limit: 20, cursor: "next-cursor" });
// page.data → RecordItem<Contact>[]
// page.hasNextPage → boolean
// page.cursor → string | null
// Search with filters
const results = await contacts.search({
filters: [{ field: "name", operator: "contains", value: "Acme" }],
sort: [{ field: "createdAt", direction: "desc" }],
limit: 50,
});
// Bulk create (up to 1000 records)
const created = await contacts.bulkCreate([
{ name: "Alice", email: "[email protected]" },
{ name: "Bob", email: "[email protected]" },
]);Record updates use full-replacement semantics. Pass the complete record data;
ordinary fields omitted from update() are removed by the server.
Every record carries a revision that increments on each write. To make an
update or delete compare-and-swap (fail with 409 Revision conflict when the
record changed since you read it), pass that revision back:
const record = await contacts.get("record-id");
if (record.revision === undefined) {
throw new Error("Server does not return record revisions");
}
// Guarded update — 409 if the record changed since the get above.
// `update` returns the fresh record; its revision is now record.revision + 1.
await contacts.update("record-id", data, undefined, {
expectedRevision: record.revision,
});
// Guarded delete — as a separate example, using the revision read above
// (reusing a revision from before a successful update would 409, because
// every write increments it):
await contacts.delete("record-id", undefined, {
expectedRevision: record.revision,
});Omit expectedRevision for last-write-wins (update) / unconditional delete.
Fields
const contacts = studio.objects.define<Contact>("contacts");
// List fields
const fields = await contacts.listFields();
// Create a field
const field = await contacts.createField({
fieldName: "phone",
displayName: "Phone Number",
dataType: "phone",
fieldNum: 5, // positive, 1-indexed field position
});
// Update / Delete
await contacts.updateField("fieldId", { displayName: "Mobile" });
await contacts.deleteField("fieldId");Field validation rules
Use a version of the SDK that exports FieldValidationRule and a compatible
backend before using these examples. Older Public API deployments reject
numeric rule IDs; deploy the compatible Public API before releasing the SDK.
Do not substitute a UUID string to work around that error.
import type { FieldValidationRule } from "@japan-ai-inc/studio-sdk";
// Resolve the object and field from the intended project's list/get results.
const object = studio.objects.define("<object-id>");
const fields = await object.listFields();
const indexField = fields.find(field => field.fieldName === "chunk_index");
if (!indexField) throw new Error("Target field not found");
const rules: FieldValidationRule[] = [{ id: 10 }]; // IntegerOnly
// Replaces the complete rule list; use only when this is the intended list.
await object.updateField(indexField.fieldId, { validationRules: rules });validationRules uses numeric IDs and optional config, not UUIDs. Rule
configuration JSON is opaque: preserve keys and values, including nested
objects in AllowedValues (id: 4). The SDK preserves this content in direct
Public API and PRS responses.
Field PATCH semantics:
- Omit
validationRulesto preserve the existing rule list. - Send
validationRules: []only when intentionally clearing all rules. - Send a nonempty array to replace the entire list, not merge individual rules. Read the current list first and include every rule that should remain.
DateRange (id: 6) bounds must be timestamp strings with seconds and a timezone:
const dateRules: FieldValidationRule[] = [
{
id: 6,
config: {
min: "2026-01-01T00:00:00Z",
max: "2026-12-31T23:59:59.999+09:00",
},
},
];Do not supply date-only strings, numeric timestamps, invalid calendar dates,
or timestamps without seconds or a timezone. Omit min or max only when
that side should be unbounded. A supplied DateRange replaces the previous
rule: omitting a bound does not preserve the old bound. Omitting both bounds
makes the range unbounded.
The compatible Public API rejects invalid supplied DateRange bounds. The SDK itself does not validate them at runtime, and direct PRS calls do not pass through that Public API guard. Do not assume PRS rejects malformed bounds; use the format above on both paths.
Adding or tightening a constraint can trigger validation of existing records.
An HTTP success response alone does not mean the new constraint is active.
Re-read fields with listFields() until the intended validationRules appear;
where the response exposes constraintValidationState, require READY and no
validation error. Stop and inspect failures or a validation timeout instead
of blindly replaying the write. Never delete and recreate a field to restore
its rules, or use a guessed parent object ID to make a write seem harmless.
Search Filter Operators
| Operator | Description |
| ------------------------- | ----------------------- |
| eq | Equal |
| ne | Not equal |
| gt / gte | Greater than / or equal |
| lt / lte | Less than / or equal |
| contains | String contains |
| startswith / endswith | String prefix/suffix |
| in / notin | Value in/not in array |
| isnull / isnotnull | Null check |
| exists | Field exists |
| regex | Regex match |
| between | Range check |
| jsoncontains | JSON field contains |
Storage
Upload, download, and manage files and folders.
List Files
const { files, folders, totalCount, nextCursor } = await studio.storage.list({
path: "/documents",
recursive: true,
limit: 50,
orderBy: "createdAt",
orderDirection: "desc",
});Upload Files
// Single file
const result = await studio.storage.upload(file, { path: "/uploads" });
// Multiple files (up to 20)
const result = await studio.storage.upload([file1, file2, file3], {
path: "/uploads",
});
// result.files → StorageFile[]
// Atomically replace files with matching names in the target path.
// Existing rows keep the same fileId; non-conflicting files are created.
const replaced = await studio.storage.upload(file, {
path: "/uploads",
conflictResolution: "replace",
});Download
// Download file content
const response = await studio.storage.download("file-id");
const blob = await response.blob();
// Get a signed download URL
const { downloadUrl, expiresAt } = await studio.storage.getDownloadUrl("file-id", { expiresInMinutes: 60 });
// Download an entire folder as a zip (direct and PRS server-runtime modes)
const response = await studio.storage.downloadFolder("folder-id", {
maxSizeMb: 100,
});downloadFolder() is not available in PRS browser mode.
File Metadata & Deletion
const metadata = await studio.storage.getMetadata("file-id");
// metadata.fileId, .fileName, .path, .mimeType, .sizeBytes
await studio.storage.deleteFile("file-id");
// { fileId, message }Folders
// Create a folder
const folder = await studio.storage.createFolder({
folderName: "reports",
path: "/documents",
});
// List folder contents
const contents = await studio.storage.listFolderContents("folder-id", {
recursive: true,
limit: 100,
});
// Delete a folder
const result = await studio.storage.deleteFolder("folder-id");
// result.deletedCount — number of items deletedResumable Upload (Large Files)
For large files, use the two-step prepare → finalize flow:
// 1. Prepare — get a signed upload URL
const prepared = await studio.storage.prepareUpload({
fileName: "large-video.mp4",
fileSize: 500_000_000,
mimeType: "video/mp4",
path: "/media",
});
// 2. Upload directly to storage (using the signed URL)
await fetch(prepared.uploadUrl, {
method: "PUT",
headers: prepared.requiredHeaders,
body: fileContent,
});
// 3. Finalize — register the file in Studio
const file = await studio.storage.finalizeUpload({
storageId: prepared.storageId,
fileName: "large-video.mp4",
fileSize: 500_000_000,
mimeType: "video/mp4",
});To atomically replace an existing file with the same name and path, pass
conflictResolution: "replace" to prepareUpload() and send the returned
replaceIntent back unchanged when finalizing:
const prepared = await studio.storage.prepareUpload({
fileName: "large-video.mp4",
fileSize: 500_000_000,
mimeType: "video/mp4",
path: "/media",
conflictResolution: "replace",
});
await fetch(prepared.uploadUrl, {
method: "PUT",
headers: prepared.requiredHeaders,
body: fileContent,
});
const file = await studio.storage.finalizeUpload({
storageId: prepared.storageId,
replaceIntent: prepared.replaceIntent,
fileName: "large-video.mp4",
fileSize: 500_000_000,
mimeType: "video/mp4",
path: "/media",
});Workflows
Create, publish, trigger, and monitor workflows.
List Workflows
const { workflows, totalCount, nextCursor } = await studio.workflows.list({
limit: 10,
});By default only workflows with a published snapshot are returned. Use status
to include drafts:
// Draft-only workflows (no published snapshot)
const drafts = await studio.workflows.list({ status: "draft" });
// Everything — each row carries isPublished
const all = await studio.workflows.list({ status: "all" });
for (const wf of all.workflows) {
console.log(wf.name, wf.isPublished);
}Get Workflow
const workflow = await studio.workflows.get("workflow-id");
// workflow.id, .name, .description, .enabled, .isPublished
// workflow.definition: { graph, features, source: "draft" | "v1" } | nulldefinition is the stored draft (or the published v1 snapshot for a legacy
workflow without a draft). Webhook secrets inside graph are redacted to
placeholders, so a definition read back can be edited and passed to
saveDraft() or publish() as-is. definition.draftUpdatedAt is an opaque
concurrency token: pass it back as expectedDraftUpdatedAt and the write is
rejected with 409 if someone else changed the draft in between.
Create a Workflow
Requires the workflow:write scope on the Project API key. The new workflow
starts with an empty draft and is not published.
const workflow = await studio.workflows.create({
name: "Invoice intake",
description: "Routes inbound invoices", // optional
enabled: true, // optional, defaults to true
});
// workflow.isPublished === falseUpdate Workflow Metadata
Requires the workflow:write scope. At least one field must be provided.
Setting enabled: false also deactivates the workflow's email and storage
triggers.
const updated = await studio.workflows.update(workflow.id, {
name: "Invoice intake v2",
description: "Routes inbound invoices",
enabled: false,
});Save a Draft
Requires the workflow:write scope. Stores the definition as the workflow's
draft without publishing it. Read the current definition first and pass its
token back so concurrent edits cannot silently overwrite each other.
const current = await studio.workflows.get(workflow.id);
if (!current.definition) throw new Error("Workflow has no definition yet");
const saved = await studio.workflows.saveDraft(workflow.id, {
graph: editGraph(current.definition.graph), // your edit of the read-back graph
features: { sandbox_provider: "e2b" }, // optional; "e2b" | "k8s"
expectedDraftUpdatedAt: current.definition.draftUpdatedAt, // optional; 409 if the draft moved
});
// saved.snapshotId, saved.draftUpdatedAtEvery successful save spends the token you passed in and returns the
replacement in draftUpdatedAt; use that one for the next saveDraft() or
publish() instead of re-reading.
Publish a Workflow
Requires the workflow:write scope. Publishing stores the supplied graph as
the workflow's live v1 snapshot and registers its triggers. Triggers whose
external connection could not be registered are reported in
unregisteredTriggers; the publish itself still succeeds. Start from a
definition read back with get() (or the draft you just saved) rather than
assembling a graph by hand.
const current = await studio.workflows.get(workflow.id);
if (!current.definition) throw new Error("Workflow has no definition yet");
const { snapshot, unregisteredTriggers } = await studio.workflows.publish(workflow.id, {
graph: editGraph(current.definition.graph),
features: current.definition.features,
expectedDraftUpdatedAt: current.definition.draftUpdatedAt, // or saved.draftUpdatedAt after saveDraft()
});
// snapshot.id, snapshot.version ("v1")
for (const trigger of unregisteredTriggers) {
console.warn(trigger.type, trigger.nodeId, trigger.reason);
}The graph body is validated server-side. A 409 means either the workflow is
release-managed (installed from an app) or the draft changed since the token
was read.
Delete a Workflow
Requires the workflow:delete scope. Deleting also deactivates the workflow's
trigger tasks.
await studio.workflows.delete(workflow.id);CLI
jai-studio workflows create --name "Invoice intake"
jai-studio workflows get <workflow-id> | jq .definition > ./definition.json
jai-studio workflows update <workflow-id> --name "Invoice intake v2" --disable
jai-studio workflows save-draft <workflow-id> --file ./definition.json
jai-studio workflows publish <workflow-id> --file ./definition.json
jai-studio workflows delete <workflow-id>--file takes the definition object exactly as workflows get prints it:
graph (with nodes and edges) and, when present, features are sent,
draftUpdatedAt is forwarded as the expectedDraftUpdatedAt precondition, and
other keys such as source are ignored. A present but malformed token is an
error rather than a silent fallback. Edit that file rather than writing a
graph from scratch.
save-draft prints the replacement draftUpdatedAt; a later publish from
the same file must carry that token (update the file or re-run get). To go
live directly, skip save-draft and publish the edited get output.
Run a Workflow
const { runId, status } = await studio.workflows.run("workflow-id", {
inputs: { documentUrl: "https://..." },
});
// Fetch outputs and keep polling if the returned status is still RUNNING
const result = await studio.workflows.getRunResult(runId);
// status: "RUNNING" | "SUCCEEDED" | "FAILED" |
// "PARTIAL_SUCCEEDED" | "STOPPED"
// result.outputs: Record<string, unknown>run() starts an execution but is not currently a guaranteed
fire-and-forget transport: the runtime may keep the request open until the
workflow reaches a terminal state. If the API returns a WORKFLOW_TIMEOUT
408 error after execution may have started, WorkflowsApiError.responseBody
includes the recoverable runId; pass it to getRunResult() to continue
checking that execution.
Run and Wait
Blocks until the workflow completes (default timeout: 60 seconds):
const result = await studio.workflows.runAndWait("workflow-id", {
inputs: { query: "market trends" },
timeoutMs: 120_000,
});
if (result.status === "SUCCEEDED" || result.status === "PARTIAL_SUCCEEDED") {
console.log(result.outputs);
} else {
console.error(result.error);
}timeoutMs is an end-to-end deadline. Supported range is 1,000–270,000 ms;
the SDK reserves the remaining 30 seconds of the PRS five-minute request budget
for authentication, proxying, and response delivery.
Members
List the members of the current project.
Direct development may use a Project API key. PRS-hosted page apps require PRS
to allow-list GET /api/v1/project-members before this module can be used in
browser mode without an API key.
GET /api/v1/project-members has three independent prerequisites, and a
missing one answers 403 with a message that names it:
| Prerequisite | Message when missing |
| -------------------------------------------------- | --------------------------------------------------------- |
| member:read on the key | API key missing required scope(s): member:read |
| EnableProjectMemberPublicApi on the organization | Project member API is not enabled for this organization |
| IAM permission for the caller | a 403 from the IAM check |
A plain Invalid API key means the key itself did not resolve — it was
revoked, belongs to another environment, or is the masked value printed by
config show. It does not indicate a scope or flag problem.
List Members
const { members, totalCount, nextCursor } = await studio.members.list({
search: "ada",
limit: 50,
cursor: "next-page-cursor",
});
// members[0].id, .principalId, .principalType, .name, .email, .avatarUrl, .createdAtCLI
jai-studio members list
jai-studio members list --search ada --limit 50
jai-studio members list --cursor <next-cursor>totalCount is the project total, so a membership count needs one call, not a
full pagination pass. Member records are personal data: keep them out of logs,
fixtures, and generated examples.
Platform (PRS) — Beta
Beta: PRS is an internal runtime. This module's API may change between minor versions. Pin to an exact SDK version if you depend on it.
Browser-only module for apps running inside Japan AI's Pages Rendering Service. This module does not require baseUrl or apiKey — PRS injects authentication automatically.
Detect PRS
if (studio.platform.isAvailable()) {
const context = studio.platform.getContext();
console.log(context.orgName, context.userName);
}Wait for PRS Ready
PRS injects window.JapanAI asynchronously. Use waitForReady() to wait:
const context = await studio.platform.waitForReady(5000); // timeout in ms
if (context) {
// PRS is ready
console.log(context.userId, context.projectId, context.pageId);
} else {
// Not running on PRS, or timed out
}Platform Context
getContext() returns:
type PlatformContext = {
userId: string;
email: string;
userName: string;
memberRole: string;
orgId: string;
orgName: string;
projectId: string;
pageId: string;
customObjects: Record<string, string>; // name → objectId
};Open Task
Delegate data to an agent task in the Studio UI:
const result = await studio.platform.openTask(
[{ name: "John", email: "[email protected]" }], // data
"Please review this contact", // user prompt
{
agentLabel: "review-agent",
isTemporary: false,
isEphemeral: false,
}
);
console.log(result.roomId);Logout
Revoke PRS sessions for the current Page session subject from a user-facing logout button:
await studio.platform.logout();This revokes every PRS session that shares the current Page session subject and
Page scope, clears this browser's current Page cookie, and reloads the Page
document. Other Pages and the Japan AI account are unaffected. On externally
authenticated Pages, that subject is the external IdP subject, so the reload
re-enters that Page's external login flow. Do not manually clear PRS cookies or
construct /__internal/session/logout URLs.
Open App Settings
When a Page is an installed App's only entry, users cannot reach the App settings through the Studio navigation. Open the settings dialog over the Page instead, from the Page's own settings button or from an error that asks the user to connect a connector:
await studio.platform.openAppSettings("connectors");The optional tab is "basic", "members", "iamRoles", "connectors",
"secrets", or "dataIntegration". The dialog shows only the tabs the viewer
can use, so it may open on another one. Members who do not manage the App still
use it to connect their personal credentials, so show the entry to every member
rather than hiding it by memberRole.
The promise rejects with code BAD_REQUEST for an unknown tab, and with
UNSUPPORTED when the Page runs in a Studio project rather than an installed
App, the organization has not enabled the feature, or the Page is opened
directly instead of embedded. Hosts that do not handle the
request, such as public share links, leave it unanswered, so it rejects with
TIMEOUT after 30 seconds. Catch these and keep the rest of the Page working.
Chat (Low-Level PRS Proxy)
Direct access to PRS's /api/agent-chat endpoint (prefer studio.agents.invoke() instead — it auto-routes on PRS):
Use agentLabel here when you intentionally need the legacy label path,
including for a label that is itself shaped like a canonical UUID. Label-only
calls do not use release-managed Agent ID resolution or its version/runtime
pinning.
const response = await studio.platform.chat({
messages: [{ role: "user", content: "Hello" }],
agentLabel: "support-agent",
model: "gpt-4o",
temperature: 0.7,
});
console.log(response.content, response.roomId);Error Handling
Each module throws its own error class, all extending SdkApiError:
import { ObjectsApiError, AgentsApiError, StorageApiError, WorkflowsApiError } from "@japan-ai-inc/studio-sdk";
try {
await studio.agents.invoke("agent-id", { prompt: "hello" });
} catch (err) {
if (err instanceof AgentsApiError) {
console.error(err.statusCode); // 401, 404, 500, etc.
console.error(err.message); // Human-readable error
console.error(err.responseBody); // Raw error response
console.error(err.code); // The route's refusal code, e.g. "PUBLIC_SHARING_DISABLED", when sent
}
}Timeouts & Cancellation
Default timeout is 30 seconds. Override per-request or globally:
// Global timeout
const studio = createClient({ baseUrl: "...", apiKey: "...", timeoutMs: 60000 });
// Per-request timeout
await contacts.list({ limit: 10 }, { timeoutMs: 5000 });
// Cancel with AbortController
const controller = new AbortController();
const promise = contacts.search({ filters: [...] }, { signal: controller.signal });
controller.abort();Agent invoke has longer defaults: 5 minutes for invoke(), 10 minutes for invokeStream().
Versioning & Stability
This package follows Semantic Versioning.
Pre-1.0 policy (0.x.y): The SDK is in active development. Minor version
bumps (0.2.0, 0.3.0) may contain breaking changes to method signatures,
error shapes, or module structure. Patch versions within the same minor are
backwards-compatible bug fixes and security patches.
Recommendation: Pin to an exact version in package.json until 1.0:
"@japan-ai-inc/studio-sdk": "0.7.0"What counts as a breaking change:
- Removing or renaming an exported function, class, or type
- Changing the signature of a public method (required params, return type)
- Changing error class hierarchy or
statusCodesemantics - Removing a CLI command or changing its required arguments
What does NOT count as breaking:
- Adding new optional parameters, methods, or exports
- Improving error messages
- Bug fixes to match documented behavior
Release history is included in the published package as CHANGELOG.md. After a
project-local install, read it at
node_modules/@japan-ai-inc/studio-sdk/CHANGELOG.md.
