@isomorph.ai/app-sdk
v1.11.0
Published
Use upstream `[email protected]` and `@ai-sdk/[email protected]` directly in actions. The platform supplies the gateway connection and approved aliases; no model wrapper is needed. Never import the provider setup into frontend code.
Downloads
4,022
Readme
Isomorph app SDK: M1 server AI and browser reads
Use upstream [email protected] and @ai-sdk/[email protected] directly in actions. The platform supplies the gateway connection and approved aliases; no model wrapper is needed. Never import the provider setup into frontend code.
// actions/summarise.ts
import { generateText, tool, jsonSchema, stepCountIs } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { createClient } from '@isomorph.ai/app-sdk';
export default async function summarise() {
const gateway = createOpenAI({
baseURL: process.env.OPENAI_BASE_URL,
apiKey: process.env.OPENAI_API_KEY,
});
const isomorph = createClient();
const result = await generateText({
model: gateway.chat(process.env.ISOMORPH_AI_GENERATION_MODEL_ALIAS!),
prompt: 'Summarise the approved channel.',
stopWhen: stepCountIs(5),
tools: {
readChannel: tool({
description: 'Read the approved team channel.',
inputSchema: jsonSchema<Record<string, never>>({ type: 'object', properties: {}, additionalProperties: false }),
execute: () => isomorph.integrations.execute('company-slack', {
operation: 'slack.channel.history', resource: 'APP_APPROVED_CHANNEL', input: { limit: 10 },
}),
}),
},
});
return { text: result.text };
}Replace the connection/resource with the app's existing approved integration. Tools inherit the invoking person's signed identity. Expose read operations only; existing SDK write methods are not M1 agent tools.
For images call generateImage({model: gateway.image(process.env.ISOMORPH_AI_IMAGE_MODEL_ALIAS!), prompt: 'A blue notebook'}). Editing uses prompt: {text: 'Make it red', images: [inputBytes]} only when isomorph ai models advertises edit. Reuse app files upload/signed reads for authorized input and output storage; respect existing transfer/action limits and never return credentials.
Company integrations
isomorph.integrations.execute(connection, request) is one method over a closed
set of operations; the operation fixes whose identity it runs under, and the
contract's bounds are checked in the client before any request is sent (the
same sentence the platform answers with). The connection, operation, resource
and mode are literals in the call: the kit gate derives
.isomorph/integrations.json from them. isomorph dev without --real
answers every operation from a local fixture.
Slack. A history, post, edit or delete runs as the app or as the signed-in
person (mode, the one IT approved for the app); a DM and a user lookup run
as the app only, against the fixed workspace resource. A post or DM needs a
fresh UUID per explicit Send press.
// A thread reply in Slack mrkdwn
const page = await isomorph.integrations.execute('company-slack', {
operation: 'slack.channel.history', resource: 'team-updates', input: { limit: 100 },
});
await isomorph.integrations.execute('company-slack', {
operation: 'slack.message.post', resource: 'team-updates',
input: { text: '*Done* — see <https://example.com/report|the report>', format: 'mrkdwn', threadId: page.messages[0].id },
idempotencyKey: crypto.randomUUID(), mode: 'app',
});
// Paged history: hasMore and nextCursor say when and how to read the next (older) page
const older = await isomorph.integrations.execute('company-slack', {
operation: 'slack.channel.history', resource: 'team-updates', input: { limit: 100, cursor: page.nextCursor },
});
// Edit or delete a message the app posted (its ts is the messageId)
const posted = await isomorph.integrations.execute('company-slack', {
operation: 'slack.message.post', resource: 'team-updates', input: { text: 'Deploy starting' }, idempotencyKey: crypto.randomUUID(), mode: 'app',
});
await isomorph.integrations.execute('company-slack', {
operation: 'slack.message.update', resource: 'team-updates', input: { messageId: posted.messageId, text: 'Deploy finished' }, mode: 'app',
});
await isomorph.integrations.execute('company-slack', {
operation: 'slack.message.delete', resource: 'team-updates', input: { messageId: posted.messageId }, mode: 'app',
});
// A direct message by email (1 to 8 people; several open one group DM) and a user lookup
await isomorph.integrations.execute('company-slack', {
operation: 'slack.dm.send', resource: 'workspace', input: { to: ['[email protected]'], text: 'Your review is due today.' },
idempotencyKey: crypto.randomUUID(),
});
const { userId } = await isomorph.integrations.execute('company-slack', {
operation: 'slack.user.lookup', resource: 'workspace', input: { email: '[email protected]' },
});Gmail runs as the signed-in person after their consent, on the fixed inbox
resource.
// Search, then read a whole thread
const found = await isomorph.integrations.execute('company-gmail', {
operation: 'gmail.thread.list', resource: 'inbox', input: { q: 'from:jane newer_than:7d', limit: 50 },
});
const thread = await isomorph.integrations.execute('company-gmail', {
operation: 'gmail.thread.read', resource: 'inbox', input: { threadId: found.threads[0].id },
});
// found.nextPageToken, when hasMore, is the next read's pageToken
// An attachment: a message or thread read names them (id, filename, mimeType, size); this fetches the bytes
const file = thread.emails[0].attachments?.[0];
if (file) {
const { dataBase64 } = await isomorph.integrations.execute('company-gmail', {
operation: 'gmail.attachment.read', resource: 'inbox', input: { messageId: thread.emails[0].id, attachmentId: file.id },
});
}
// An HTML send: text stays the plain-text alternative; up to 5 attachments of 2 MiB each
await isomorph.integrations.execute('company-gmail', {
operation: 'gmail.message.send', resource: 'inbox',
input: {
to: ['[email protected]'], subject: 'Weekly report', text: 'The report is attached.',
html: '<p>The report is <b>attached</b>.</p>',
attachments: [{ filename: 'report.csv', contentType: 'text/csv', dataBase64: csvBase64 }],
},
idempotencyKey: crypto.randomUUID(),
});(1.8.0; contracts 0.71.0)
Browser read tools
Install optional peers ai and [email protected]. Locally run npx playwright-core install chromium --only-shell; V2 packages Chromium in the existing app image. An existing system executable can be supplied as executablePath.
import { createBrowserTools } from '@isomorph.ai/app-sdk/dist/browser.js';
// Inside an action with its context:
const browser = await createBrowserTools({
pages: ['https://example.com/'],
signal: context.signal,
});
try {
// Add browser.tools to generateText's tools; keep stopWhen: stepCountIs(5).
} finally {
await browser.close();
}App code declares exact HTTPS pages and named click/fill selectors reviewed for reads. Tools provide navigate, observe, control, scroll, screenshot and close. Observations are untrusted webpage content. One isolated page per run, one active run per process, 50 actions, five minutes, ten-second operations, bounded text/screenshots. Private destinations, undeclared navigation, downloads, popups, WebSockets, service workers and non-GET/HEAD requests are blocked. GET-only filtering cannot infer website business semantics: never declare URLs/controls that perform external writes. Chromium's sandbox remains enabled; unsupported sandbox environments fail clearly.
Running and rollout
isomorph dev uses canned server-model fixtures. dev --real supplies an approved development connection to the action process. Hosted actions resolve their exact ACTIVE version key. The current V2 PLAN is published after LIVE; before key activation, an AI action fails and later actions retry. LIVE alone is not AI acceptance.
Requires compatible SDK/CLI/toolkit publication, gateway protocol fixes, governance connection routes and V2 packaging/version wiring. No deployment is claimed here. Real model/file authorization and hosted Chromium sandbox checks remain release acceptance. Search, company skills, integration/browser writes, video, audio, STT, TTS and realtime audio are deferred.
Where the browser client sends its calls
createClient() with no baseUrl picks its base URL like this:
- In Node (the retained-check runner, the kit runtime),
ISOMORPH_GATEWAY_URL. - In a browser,
window.__isomorphApiOriginwhen it is anhttp(s)origin string such ashttps://fourier-app-x.api.preview.beta.apps.isomorph.ai. A deployed kit page carries the platform's credential shim, a<script>in<head>that sets this global to the app's own API host and wrapswindow.fetchso calls there carry the sign-in cookie; the realtime socket andfiles.getPublicUrlfollow the same host. Nothing in the app sets it. - Otherwise the page-relative default (
'', or/p/<org>.<app>on the shared edge): a localisomorph runand a page without the shim are unchanged.
A value that is not an http(s) origin — a path, a trailing slash, another
scheme, a non-string — is ignored. An explicit baseUrl always wins.
A refused call (401) is renewed once and resent once. On the page's own
origin the renewal is the identity GET, which the platform edge heals. On a
separate API host (the global above, when it is not the page origin) it is a
credentialed GET <api>/__harbour/context/refresh?return_to=%2F_harbour%2Fidentity%2Fcurrent:
the host mints a fresh identity cookie from its sign-in session and redirects
to the identity GET. When that refresh does not end in a 200 with the user —
the session is gone, the network failed — the original 401 is surfaced
without a resend, and the page's sign-in handling takes over.
On a separate API host the identity GET itself (identity.current(), and the
background refresh every 60 s that drives identity.onChange) is renewed the
same way: its refresh ends at the identity GET, so that answer is the identity
and no second GET is sent. When the refresh fails, identity.current()
answers null and listeners hear null, as before. On the page's own origin
the identity GET is never renewed (it is the renewal). (1.5.3)
The signed-in user's groups
user.groups — the company groups your identity provider sent at sign-in; use
for in-app roles. App access itself is enforced before your code runs.
It is on identity.current(), identity.onChange and an action's ctx.user,
and it is always an array ([] when the identity provider sent none):
// actions/approve.ts
export default async function approve(input: { id: string }, ctx: { user: { groups: string[] } }) {
if (!ctx.user.groups.includes('Finance')) throw new Error('Only Finance can approve.');
// ...
}(1.6.0)
Live progress from an action
An action reports steps with ctx.progress(step) (a string or a small JSON
object); a page that passes onProgress gets each one as it happens, then the
result or the error exactly as a plain invoke gives it. No table, no polling.
// actions/research.ts
export default async function research(input: { url: string }, ctx: { progress(step: string): void }) {
ctx.progress(`Opened ${input.url}`);
// ...
return result;
}
// page
await isomorph.actions.invoke('research', input, { onProgress: (step) => setSteps((steps) => [...steps, step]) });Without onProgress the call is unchanged and ctx.progress does nothing. At
most 256 steps of up to 1 KiB each reach the page; further steps are dropped.
The action still has the 300 s action budget. (Unreleased)
