TypeScript / JavaScript SDK
The official TypeScript / JavaScript client SDK for Scrydon — OAuth/PKCE auth and a typed surface for workflows, knowledge, storage, LLM completions, and webhooks
@scrydon/sdk is the official client SDK for browser, TanStack Start / Next.js, and Node.js apps. It authenticates end-users through OAuth 2.0 with PKCE and exposes typed modules for workflows, governed LLM completions, knowledge, storage, and webhooks, plus the platform data / ai / action SDK surface.
This SDK is for first-party apps acting on behalf of a user (OAuth flow). For server-to-server execution with a static API key — backends, cron jobs, CI — call the workflow API directly with x-api-key (see Execution).
Installation
bun add @scrydon/sdknpm install @scrydon/sdkyarn add @scrydon/sdkQuick Start
import { ScrydonClient } from '@scrydon/sdk'
const client = new ScrydonClient({
baseUrl: 'https://scrydon.com',
clientId: 'your-oauth-client-id',
redirectUri: 'https://your-app.com/auth/callback',
})
// 1. Start the OAuth/PKCE flow — returns the authorization URL
const authUrl = await client.auth.signIn()
window.location.assign(authUrl)
// 2. On your callback page, exchange the code for a session
const session = await client.auth.handleCallback() // reads window.location by default
// 3. Call typed APIs
const result = await client.workflows.trigger({
workflowId: 'wf_…',
inputs: { message: 'Hello, world!' },
})
console.log(result.executionId, result.status, result.outputs)Authentication — OAuth 2.0 + PKCE
The SDK uses the PKCE flow so it's safe in browsers without a client secret. The verifier is held in sessionStorage (browser) or in-memory (other runtimes).
auth.signIn()
const authUrl = await client.auth.signIn()
// Redirect the user to authUrl. After consent they come back to redirectUri
// with ?code=… and ?state=… in the query string.auth.handleCallback(callbackUrl?)
// On the callback route — defaults to window.location.href
const session = await client.auth.handleCallback()
// session: { user: { id, email, name? }, accessToken, expiresAt }auth.getSession()
const session = await client.auth.getSession() // null if signed out / expiredauth.signOut()
await client.auth.signOut()auth.onAuthStateChange(cb)
const unsubscribe = client.auth.onAuthStateChange((session) => {
if (session) {
console.log('signed in as', session.user.email)
} else {
console.log('signed out')
}
})
// Later: unsubscribe()client.workflows
trigger(options) — synchronous
const result = await client.workflows.trigger({
workflowId: 'wf_abc',
inputs: { message: 'Hello' },
})
// result: { executionId, status, outputs? }POST /api/v1/workflows/{workflowId}/trigger. Best for short-running workflows that respond inline.
triggerAsync(options) — fire and poll
const { executionId } = await client.workflows.triggerAsync({
workflowId: 'wf_abc',
inputs: { message: 'Long task' },
})
// Poll for status when you're ready
const result = await client.workflows.getStatus(executionId)
// result: { executionId, status: 'running' | 'completed' | 'failed', outputs? }POST /api/v1/workflows/{workflowId}/trigger?async=true. Use for long-running workflows.
getStatus(executionId)
const result = await client.workflows.getStatus(executionId)GET /api/v1/workflows/executions/{executionId}.
client.chat
OpenAI-compatible governed LLM completions. The SDK does not create a public workflow-chat session; send the conversation history in messages and store it in your application if needed.
const stream = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'Summarize Q3' }],
stream: true,
})
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta.content ?? '')
}See the SDK 1.0 migration guide if you previously used client.chat.stream or useChatStream.
client.knowledge
const hits = await client.knowledge.query({
question: 'What is our refund policy?',
topK: 5,
})
// hits: Array<{ content, source, score }>
await client.knowledge.ingest({
documents: [file1, file2],
collection: 'policies',
})client.ontology
Map-ready geo layers projected from your ontology — one layer per object type
whose instances carry coordinates (latitude/lat, longitude/lng/lon).
Omit all options to discover every mappable type across your readable
ontologies; pass objectTypes to narrow.
const layers = await client.ontology.queryGeo({
objectTypes: ['Aircraft', 'MilitaryBase'], // optional — omit to discover
ontologySlug: 'my-domain', // optional — omit to search all
limit: 300, // max instances per type (1–1000)
})
// layers: Array<{ objectTypeSlug, label, features }>
// features: Array<{ id, lat, lng, label, properties }>Geo is a dedicated projection rather than a generic instance query: coordinate extraction, range validation, and binding traversal run server-side, so the client receives render-ready features.
client.storage
const { url } = await client.storage.upload(file, { path: 'reports/q3.pdf' })
const directLink = await client.storage.getUrl('reports/q3.pdf')client.webhooks
A simple in-process event emitter for delivering webhook payloads to subscribers in your app.
const off = client.webhooks.on('workflow.completed', (data) => {
console.log('workflow completed', data)
})
// Later: off()Platform SDK — client.data, client.ai, client.action
The client exposes the platform's typed ScrydonSDK:
client.data.knowledge.search(...),client.data.knowledge.memex.query(...),client.data.storage.read(...),client.data.memory.add(...)— read / write the workspace data surfaceclient.ai.llm.complete(...)— capability-resolved LLM callsclient.action.workflows.execute(...),client.action.email.send(...),client.action.sms.send(...),client.action.functions.execute(...),client.action.collaboration.post(...)— server-side actions
See @scrydon/sdk/types for the full set of platform types.
The platform SDK carries surfaces beyond the four exposed on ScrydonClient.
Build the full client with createScrydonSDK from @scrydon/sdk/platform to
reach them — for example platform.geo.*, documented in
Geo. client.action.collaboration.post is documented in
Collaboration: it posts only from inside an
automation run (a workflow, a process flow or a Cortex turn), and it answers
CAPABILITY_NOT_CONFIGURED where the Cortex messaging service does not run
(see Availability).
Bounding the answer — outputConstraints
Asking a model for "a one-pager" in the prompt is a request, not a limit.
outputConstraints on client.ai.llm.complete(...) and its stream is the
enforceable form. The platform renders the limits into the prompt, measures
the answer, and reports what it measured:
const result = await client.ai.llm.complete({
messages: [{ role: "user", content: "Summarise the attached tender." }],
outputConstraints: { maxWords: 300 },
});
result.constraintReport;
// { format: "text", breached: false, repaired: true, repair: "accepted",
// measurements: [{ measure: "words", limit: 300, measured: 287, breached: false }] }| Field | Bounds |
|---|---|
maxWords | Words. CJK ideographs and kana count one per character; Markdown syntax does not count. |
maxCharacters | Unicode code points. |
maxTokens | Estimated tokens (ceil(characters / 4)). The top-level maxTokens is still the provider's hard cut-off; this one is measured and repaired. |
maxListItems | Items in the longest single list (Markdown list or JSON array). |
maxSections | Markdown headings, or top-level properties of a JSON object. |
onBreach | "repair" (default) or "report". |
Declare at least one limit. Unknown fields are rejected with INVALID_REQUEST.
A breach never fails the call. A longer answer is worse, but it is still an answer:
- Unary. With
onBreach: "repair", an answer over a limit gets one condense pass. The rewrite is used only if it is shorter and has not collapsed or invented citations, and it is governed by DLP and moderation like any other output. If it passes,repairedistrue. If it fails, or no rewrite fits in the model's context window, you get the first draft back withbreached: true. - Streaming. The text has already reached the caller, so a stream is
measured at the end and never repaired. The report arrives on the
terminal
completeevent withrepair: "skipped". - What is measured. The report always counts the text you receive,
after DLP. If DLP redacted part of the answer, the measurements count the
redacted text, not the model's draft.
repairstill describes the draft, so a redaction can givebreached: truewithrepair: "not_needed". - JSON answers. Words are counted over the string values only. A repair
must return the same JSON with only its prose shortened: the same keys at
every level, every number, boolean and single-word value (such as a verdict
or an id) unchanged, and array entries dropped only when
maxListItemswas exceeded. Otherwise the rewrite is rejected and you get the first draft. Because a repair keeps every key, a JSON object overmaxSectionsis never repaired: the report saysrepair: "skipped"withrepairSkipReason: "json_structure", and no repair call is made.
To count text the same way the platform does, for example text a person
edited afterwards, use measureLlmOutput(text, constraints) from
@scrydon/sdk/platform.
Extension code passes the same field through ctx.ai.llm.complete(...),
typed as LLMOutputConstraints from @scrydon/sdk-authoring/extensions/platform,
and receives the same constraintReport.
A failed platform call throws PlatformApiError from @scrydon/sdk/platform.
Branch on its code and retryable, comparing against the keyed
PLATFORM_ERROR_CODE constant (PLATFORM_ERROR_CODE.CAPABILITY_NOT_CONFIGURED)
rather than retyping the string: a misspelled key fails type-checking, a
misspelled string never matches. Where one code covers several causes,
details.reason names the cause with a PLATFORM_ERROR_REASONS value:
code | details.reason | Meaning |
|---|---|---|
INVALID_REQUEST | AUDIO_REFERENCE_NOT_FOUND | An audioRef key names no object in your organization's store. |
INVALID_REQUEST | AUDIO_REFERENCE_OUT_OF_SCOPE | An audioRef names another context, workspace or environment. |
INVALID_REQUEST | AUDIO_REFERENCE_TOO_LARGE | The referenced audio exceeds what one reference may carry (64 MiB). |
INTERNAL_ERROR | STREAM_ENDED_WITHOUT_TERMINAL | The provider stream stopped without finishing; treat the output as truncated. |
All three audio reasons are retryable: false. Sending the same reference
again reads the same state.
Workflow execute result — the child run's classification
When a workflow run reads classified data, the platform tracks a
classification mark for the run. The result of a workflow execute carries the
mark the child run ended on, so a caller that runs a workflow cannot receive
its data without its classification. The result of
client.action.workflow.execute(...) (ActionWorkflowExecuteResult) declares
both fields, and the client rejects a malformed mark rather than passing it
through:
| Field | Shape | Meaning |
|---|---|---|
classification | { schemeId: string; rank: number } (optional) | The highest classification the child run read, and never below the mark it was started with. Absent only when the child started unmarked and read nothing classified. |
classificationSchemesMixed | boolean (optional) | true when the run read data under more than one classification scheme. |
- The child starts at the calling run's mark. It cannot write data out below the classification its caller has already read.
- A failed child still returns its mark, alongside its partial outputs and its error message.
- A child that crashed, was terminated, or had not finished returns no outputs and a fixed error message. Its mark is at least the one it was started with.
- If the platform cannot read the child's recorded mark, the result comes
back with
success: falseand no outputs, rather than as unclassified data. - A child started with a mark must answer with one. If the workflow
service answers without one, the call fails with
PROVIDER_CONTRACT_VIOLATION(HTTP 502) and the outputs are not returned. This can happen only while a platform upgrade is rolling out. The child has still run, so do not retry the call blindly.
Deadlines and cancellation
Every platform call is bounded. Without a setting, a call gets
PLATFORM_DEFAULT_DEADLINE_MS (120 seconds) — the same bound the platform puts on
running the operation, so the SDK never gives up on work the platform is still
doing and never waits on work it has already cut off. When the deadline passes, the
call rejects with PlatformApiError code DEADLINE_EXCEEDED (retryable: true;
retry a non-idempotent action only with an idempotencyKey).
// Give up on one call sooner than the default:
const answer = await client.ai.llm.complete({ messages }, { timeoutMs: 30_000 })
// Cancel early. A signal only ever shortens a call — it never removes the deadline.
const controller = new AbortController()
const pending = client.ai.llm.complete({ messages }, { signal: controller.signal })
controller.abort()The platform still runs each operation for at most its own 120-second bound, so a
timeoutMs above the default only waits longer for the platform's own
DEADLINE_EXCEEDED; it does not buy the operation more time.
A unary call must answer — status and body — within its deadline. A stream
(client.ai.llm.stream) must open within it; once open it runs until its
final complete or error event, so a long generation is not cut off by the
deadline. The platform ends every stream with that final event, and your signal
still cancels an open stream at any time.
A transport you build yourself takes a default for all its calls:
createHttpPlatformTransport({ baseUrl, timeoutMs }).
Discovering what a capability offers
client.extensions.capabilities.list(...) returns every provider your
organization has enabled for a capability, each with its selectable models:
const llm = await client.extensions.capabilities.list({ capability: "llm" });
llm.category; // "model" — selection descends to a model
llm.providers; // [{ providedId, extensionId, slug, name, icon?, models: [...] }]Models come back already merged: a vendor that declares a fixed catalogue and one that discovers deployments at runtime are indistinguishable in the response, so you never branch on which kind you are talking to.
When Platform could not discover a provider's models, the provider carries a
discoveryFailure with a stable code (a PlatformErrorCode) and a message
you can display. The code says whether waiting helps:
CAPABILITY_NOT_CONFIGURED means the connection itself is wrong, for example an
endpoint whose TLS certificate does not verify, and only changing the connection
clears it, while PROVIDER_UNAVAILABLE or DEADLINE_EXCEEDED is an outage worth
retrying. Its models are then only the fixed catalogue, which is empty
for a vendor that relies entirely on discovery. An empty models array without
discoveryFailure means the provider was reached and exposes nothing:
for (const provider of llm.providers) {
if (provider.discoveryFailure) {
console.warn(`${provider.name}: ${provider.discoveryFailure.message}`);
}
}A discoveryFailure may also carry a suggestedFix: one field of the
connection's configuration and the https URL value that would fix it, as the
extension proposed it. Platform includes it only after checking the value against
the extension's endpoint fields and your organization's egress allowlist. Applying
it is an ordinary connection update, which an administrator makes and Platform
checks again.
Each model has its provider-native id, its model slug, and a canonical
selectionId accepted by Platform selection and dispatch. Provider presentation
is represented once by provider.slug and optional provider.icon rather than
being copied into every model. An optional model metadata object preserves
capability-specific catalogue or discovery fields. LLM entries can include
contextWindow, pricing, and the effective capabilities after vendor
defaults and model overrides are combined. These known fields are typed by the
exported CapabilityListModelMetadata contract; additional JSON metadata
remains available for capability- and integration-specific fields. Treat every
metadata key as optional.
Use the SDK projection when a picker needs one flat, deterministic list:
import { flattenCapabilityModelSelections } from "@scrydon/sdk/platform";
const selections = flattenCapabilityModelSelections(llm);
selections[0]?.selectionId; // persist or submit this valueThe category tells you how the capability is chosen:
| Category | Meaning | models |
|---|---|---|
model | pick a provider and a model (LLM, embeddings, speech, OCR, image, video, moderation) | present |
general | pick a provider; there is nothing below it (web search) | absent |
Pass applyAllowlist: false to see the full set before your organization's
model allowlist is applied — useful when building a UI for editing that
allowlist. Pass refresh: true to bypass the discovery cache.
Discovered models are cached per connection. Right after a Scrydon upgrade, a listing can briefly return the models discovered before the upgrade while Platform discovers them again in the background. Changing the connection itself (its endpoint or credential) is never answered from the cache. Cached results still follow the caller's current data-protection policy. Cancelling a request or reaching its deadline does not prevent other callers from discovering models through the same connection.
This differs from client.extensions.capabilities.resolve(...), which returns
the single target that would serve the capability right now. A model
resolution retains the provider-native model and includes its canonical
selectionId; non-model capabilities omit both. Use resolve to learn what
will run and list to offer a choice. Consumers do not need to parse or qualify
either operation's model IDs.
For stt, resolve also accepts an optional sttArm — "batch" or
"streaming", the arm you are about to dispatch on. Pass it when you pin a
provider without a model: the platform then answers with that provider's
model serving the arm, or refuses with CAPABILITY_NOT_CONFIGURED, exactly as
the dispatch itself would. It never substitutes another provider. sttArm is
refused for any capability other than stt.
const stt = await client.extensions.capabilities.resolve({
capability: "stt",
provider: "azure-foundry",
sttArm: "batch",
});Errors
import {
ScrydonAuthError,
ScrydonForbiddenError,
ScrydonRateLimitError,
ScrydonValidationError,
ScrydonServerError,
ScrydonError,
} from '@scrydon/sdk'
try {
await client.workflows.trigger({ workflowId: 'wf_abc' })
} catch (err) {
if (err instanceof ScrydonAuthError) {
// 401 — token expired or invalid; redirect to signIn()
} else if (err instanceof ScrydonForbiddenError) {
// 403 — user lacks permission for this workflow
} else if (err instanceof ScrydonRateLimitError) {
// 429 — err.retryAfter seconds
} else if (err instanceof ScrydonValidationError) {
// 400 — err.details has the Zod issues
} else if (err instanceof ScrydonServerError) {
// 5xx — retry with backoff
} else if (err instanceof ScrydonError) {
// Other 4xx — err.code, err.status
}
}429 because the platform is at capacity. Every call to the platform API
(/api/platform/v1/…), every MCP tool call and every LLM router turn draws on
one bounded number of open calls per API Platform pod. When that budget is full
the call is refused before any work starts: 429 with a Retry-After header
and the error RATE_LIMITED, details.reason: "platform_in_flight_cap". When
the pod is more than half full and shared with other organizations, one
organization may not take more than a quarter of it, so a busy tenant is
refused with details.reason: "platform_org_in_flight_share" before it can
crowd others out. An organization alone on the pod can use all of it but four
slots, kept free for a newcomer. Calls still being authorized draw on a
separate allowance of the same size, so a newcomer gets in unless one tenant
keeps that many calls in authorization at once. If the pod fills while your
call is being authorized, it is refused with platform_in_flight_cap after
authorization, still before any work runs. Wait
retryAfter seconds and retry; nothing was run or billed. An MCP client sees
the same refusal as a JSON-RPC error -32603 (retryable) whose
data.scrydon.code is RATE_LIMITED. Sustained 429s mean the deployment
needs more capacity; ask your administrator. A request body that arrives too
slowly (by default, below 64 KiB/s after the first 10 seconds; your
administrator can lower both) is refused 400 with
details.reason: "body_read_too_slow": send it again.
React
@scrydon/sdk/react ships hooks for the common flows:
import { ScrydonProvider, useAuth, useWorkflow } from '@scrydon/sdk/react'
function App() {
return (
<ScrydonProvider config={{ baseUrl, clientId, redirectUri }}>
<Page />
</ScrydonProvider>
)
}
function Page() {
const { session, signIn, signOut } = useAuth()
const { trigger, result, loading, error } = useWorkflow('wf_abc')
if (!session) return <button onClick={signIn}>Sign in</button>
return (
<div>
<button onClick={() => trigger({ message: 'Hello' })} disabled={loading}>
Run workflow
</button>
{result && <pre>{JSON.stringify(result.outputs, null, 2)}</pre>}
</div>
)
}TanStack Start / Next.js callback route
// /auth/callback page
import { ScrydonClient } from '@scrydon/sdk'
const client = new ScrydonClient({
baseUrl: import.meta.env.VITE_SCRYDON_BASE_URL,
clientId: import.meta.env.VITE_SCRYDON_CLIENT_ID,
redirectUri: `${window.location.origin}/auth/callback`,
})
export function CallbackPage() {
useEffect(() => {
client.auth
.handleCallback()
.then(() => router.navigate({ to: '/' }))
.catch((err) => console.error('OAuth callback failed', err))
}, [])
return <p>Signing you in…</p>
}Backend / server-to-server
@scrydon/sdk is built around an end-user OAuth flow. For backend processes that need to execute workflows on behalf of the platform (cron, CI, internal services), call the API directly:
curl -X POST "https://scrydon.com/api/workflows/{workflowId}/execute" \
-H "x-api-key: $SCRYDON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input": "…"}'API keys are issued during the deploy flow (see Execution). They are the right tool for server contexts where there is no end user.
Requirements
- Node.js 18+ (for fetch + atob globals)
- TypeScript 5.0+ for full types
- A browser-like environment for the React module
License
Apache-2.0