@skaile/forge-app-actions
v0.4.3
Published
Browser-declared app actions for Skaile forge apps: defineForgeAction / defineClientCapability, the agent capability bridge, command palette and approval dialog, as a Nuxt module.
Readme
@skaile/forge-app-actions
Browser-declared app actions for Skaile forge apps, as a Nuxt module.
One defineForgeAction declaration is both a command-palette row and an agent capability. The package ships
defineForgeAction,useForgeActions,defineClientCapability(auto-imported in a Nuxt app, or imported from@skaile/forge-app-actions/client-capabilities),useAgentCapabilities(announce) anduseCapabilityInvocations(handlecapability_invokedfrom the app's SSE and post the result),useCapabilityApproval+<ForgeCapabilityApproval />(approval dialog),useCommandPalette+<ForgeCommandPalette />,- the Nitro-side bridge (
clientCapabilityBridge,capabilityRegistry) via@skaile/forge-app-actions/server.
Install
// nuxt.config.ts — @nuxt/ui must come first
export default defineNuxtConfig({
modules: ['@nuxt/ui', '@skaile/forge-app-actions'],
})Peers: @nuxt/ui ^4.11, nuxt, vue; @skaile/workspaces ^6 is an optional peer (type-only imports from @skaile/workspaces/sdk/types); ai ^7.0.124 is an optional peer, imported only by ./chat/ai-sdk.
@skaile/forge-common installs this module for you.
Exports
| Subpath | Contents |
|---|---|
| . | the Nuxt module (forgeAppActions config key) |
| ./client-capabilities | browser-side API, explicit-import entry for tests and non-auto-import contexts |
| ./chat | useChatActions, toolNameOf — forge actions as forge-chat client tools (browser) |
| ./chat/server | clientToolsFromBody — validates the clientTools request field (no Vue) |
| ./server | clientCapabilityBridge, capabilityRegistry, routeCapabilityInvocation, defineServerAction, createServerActionDefiner, forAudience, invokeAction, … |
| ./mcp | createActionsMcpServer, createActionsMcpHttpHandler, toolNameOf — the MCP projection of server actions |
| ./http | createActionsHttpHandler — the HTTP projection (manifest + calls) of user-audience server actions |
| ./client | createActionsClient, ActionsClientError — typed client of the HTTP projection (no Vue/H3/AI SDK) |
| ./chat/ai-sdk | serverActionToolSet — server actions as AI SDK tools (the only entry that imports ai) |
| ./types | ClientCapability*, ForgeAction*, Palette* types |
Moved here from @skaile-ai/forge-common 9.x (./client-capabilities and the capability parts of ./server and ./types).
Using actions from an AI-SDK chat
A plain Nuxt app with an AI-SDK route (no Skaile platform, no SSE agent session) can let the model call the browser's actions through @skaile/forge-chat ≥ 0.2 client tools. Every action whose audience includes 'llm' becomes a tool; requiresApproval actions wait for the user's confirmation first.
Tool names. Model providers reject dots in tool names (Anthropic ^[a-zA-Z0-9_-]{1,64}$, OpenAI ^[a-zA-Z0-9_-]+$), so the tool name is toolNameOf(capabilityName): every character outside [A-Za-z0-9_-] becomes _, truncated to 64. Action doc.read (capability app.doc.read) is the tool app_doc_read. Name tools in prompts with toolNameOf('app.doc.read') (exported from @skaile/forge-app-actions/chat). Two actions that map to the same tool name make descriptors() and the handler lookup throw. The handler still runs the action under its real capability name (ctx.name === 'app.doc.read'). Descriptor schemas are the zod-derived JSON Schema without the top-level $schema key.
// app/pages/index.vue
const chatActions = useChatActions({ actions: () => actions }) // from '@skaile/forge-app-actions/chat'
const transport = createAiSdkTransport({ url: '/api/chat', body: () => ({ clientTools: chatActions.descriptors() }) })
const session = createChatSession({ transport, tools: chatActions.handlers })
// app/app.vue: <ForgeCapabilityApproval /> — mount once
// server/api/chat.post.ts
import { clientToolsFromBody } from '@skaile/forge-app-actions/chat/server'
export default defineEventHandler((event) =>
createAiSdkChatHandler({ model, clientTools: (ctx) => clientToolsFromBody(ctx.body) })(toWebRequest(event)))Round trip: the model emits a tool call → the handler ends the turn with turn-end reason tool-calls → the session runs chatActions.handlers[name] → the result is pushed and the turn resumes. A handler validates the input against the action's zod schema first (invalid → the model gets invalid input: <issues>), then asks for approval when required (declined → the model gets declined), then runs the action with source: 'llm'.
clientToolsFromBody returns undefined when the body has no clientTools, and throws an error with statusCode: 400 (clientTools: <issues>) for a bad tool name, an over-long description or a non-object schema — createAiSdkChatHandler answers such an error with that status.
Peer: @skaile/forge-chat ^0.2.0 (optional; type-only imports).
Server actions: audience and authorization
defineServerAction keeps the literal id and infers input (z.input) and output types, exposed as ActionInput<A> / ActionOutput<A>; a const tuple of actions keeps every element type through forAudience and the registry.
const defineAppAction = createServerActionDefiner<AppPrincipal, AppDeps>() // binds only the app's types
export const trainings = [
defineAppAction({
id: 'trainings.delete', // canonical name `app.trainings.delete`
description: 'Delete a training.',
input: z.object({ id: z.string() }).strict(),
audience: ['user'], // not offered to models; default is both, [] throws
authorize: async (input, ctx) => { // after the one input parse, before the handler
await assertMayDelete(ctx.principal, ctx.name, input.id) // read current authority here
},
run: ({ id }, ctx) => ctx.deps.trainings.delete(id),
}),
] as const
serverActionRegistry.registerAll(trainings)
const forModels = forAudience(trainings, 'llm')runparses the input exactly once, derivesname = app.<id>(anamein the caller's context is ignored), awaitsauthorize(parsed, context)and only then calls the handler. A throwingauthorizedenies the call on every path, direct.runincluded; a malformed input never reachesauthorizeor the handler. Withoutauthorizenothing changes.- The core has no principal, role or grant vocabulary:
ctx.principalandctx.depsare whatever the transport's authorize hook set (P/Ddefault tounknown). Authorization is the app's; read the current identity and grants insideauthorize, not from a value captured when the tool set was built. audience('user','llm') only controls where an action is offered; it is not authorization.invokeAction(action, input, ctx)returns anActionOutcome,{ ok: true, result }or{ ok: false, status, message, issues? }: the action's own input failing its schema is 400 (invalid input: …, withissues), an error with a numericstatusCodekeeps it and its message, anything else (aZodErrorfromauthorizeor the handler included) is 500 with the messageInternal error, the original going toconsole.error. It callsaction.runonly and never parses or authorizes itself.
Exposing server actions over MCP
Server actions are headless: they run in Nitro, have no palette row and no browser round trip. defineServerAction declares one, serverActionRegistry (from @skaile/forge-app-actions/server, auto-imported in a Nuxt app) collects them, and forgeAppActions.mcp serves the registry as a stateless Streamable HTTP MCP endpoint (POST only). Without mcp no route exists.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/ui', '@skaile/forge-app-actions'],
forgeAppActions: { mcp: { route: '/mcp', name: 'my-app', version: '1.0.0' } }, // route defaults to '/mcp'
})
// server/plugins/mcp.ts
import * as z from 'zod'
export default defineNitroPlugin((nitroApp) => {
serverActionRegistry.register(defineServerAction({
id: 'notes.create', // capability `app.notes.create`, tool `app_notes_create`
description: 'Create a note.',
input: z.object({ text: z.string().min(1) }).strict(),
run: (input, ctx) => createNote(ctx.event as H3Event, input.text),
}))
// Authorization is the app's job. Without a hook that sets `allowed`, the route answers 401.
nitroApp.hooks.hook('forge-app-actions:mcp:authorize', async (decision) => {
if (!decision.bearer) return
const user = await userForToken(decision.bearer)
if (!user) return
decision.allowed = true
decision.principal = user // reaches the actions as `ctx.principal`
})
})curl -s -X POST http://localhost:3000/mcp \
-H 'authorization: Bearer <token>' -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"app_notes_create","arguments":{"text":"hi"}}}'Tools. Every registered action whose audience includes llm is a tool named toolNameOf(action.name); two actions that map to the same name make the server fail with Tool name collision. The description and the zod-derived input schema come from the action. A call validates the input first (invalid → isError with invalid input: <issues>); a denial by the action's authorize becomes isError with its message (an H3 error: its statusMessage), an unexpected exception isError with Internal error; an unknown name gives unknown tool: <name>. A result is returned as JSON text, and for a non-array object also as structuredContent.
Authorization. The route reads Authorization: Bearer … and calls the Nitro hook forge-app-actions:mcp:authorize with { event, bearer, allowed: false, principal, deps } (bearer is null without such a header). The hook sets allowed and, optionally, principal (ctx.principal) and deps (ctx.deps). A hook that throws or leaves allowed false gives 401 (www-authenticate: Bearer, cache-control: no-store). Non-POST requests give 405 (allow: POST); an unreadable body gives JSON-RPC -32700. The package ships no shared token on purpose: a token with a default is a token somebody forgets.
To serve actions from another HTTP stack, @skaile/forge-app-actions/mcp exports createActionsMcpServer and createActionsMcpHttpHandler.
Exposing server actions over HTTP
forgeAppActions.http mounts the actions that include the user audience as a small JSON API; without it no route exists. It is how browsers, scripts and the CLI reach the same operations MCP gives models, through the same authorize of each action.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/ui', '@skaile/forge-app-actions'],
forgeAppActions: { http: { route: '/api/actions' } }, // route defaults to '/api/actions'
})
// server/plugins/actions-http.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('forge-app-actions:http:authorize', async (decision) => {
// Bearer clients (CLI, scripts): a credential the app issued.
if (decision.bearer) {
const user = await userForToken(decision.bearer)
if (!user) return
decision.allowed = true
decision.principal = user // reaches the actions as `ctx.principal`
return
}
// Cookie sessions: the browser sends the cookie on cross-site requests too, so a POST
// needs a same-origin check HERE. The adapter does no CSRF defence of its own.
const session = await sessionOf(decision.event)
if (!session || (isMutation(decision.event) && !isSameOrigin(decision.event))) return
decision.allowed = true
decision.principal = session.user
})
})GET <route>answers{ actions: [{ id, name, description, kind, inputSchema }] }for theuser-audience actions, withcache-control: no-store.POST <route>/<id>takes the JSON body as the action's input and answers its raw result.- Both routes deny by default. The hook
forge-app-actions:http:authorizereceives{ event, bearer, allowed: false, principal?, deps? }once per request, before an id is looked up or a body is read, and answers 401 (www-authenticate: Bearer) unless it setsallowed.principalanddepsbecomectx.principal/ctx.deps. A bearer credential and a cookie session are different authentications: never infer one from the other's role. - Failures use the H3 error body (
{ statusCode, statusMessage, message, data? }): malformed JSON 400, a schema error 400 withdata.issues, an unknown action or one without theuseraudience 404, a wrong method 405, a denial by the action'sauthorizeits ownstatusCode(403 for a grant check), an unexpected error 500 with the messageInternal error. - To serve the same API from another stack (Express, plain
node:http),@skaile/forge-app-actions/httpexportscreateActionsHttpHandler({ actions, route, authorize }), a(req, res, input?)handler.authorizereceives the same decision (eventis the request unlessinput.eventis given); passinput.readBodywhen your framework already consumed the request stream.
Typed client
import type { SERVERAKTIONEN } from '~~/server/utils/serveraktionen'
import { createActionsClient } from '@skaile/forge-app-actions/client'
const actions = createActionsClient<typeof SERVERAKTIONEN>({ fetch: $fetch }) // Nuxt: inject $fetch
const created = await actions.call('trainings.create', { title: 'Abend' }) // ids, inputs, outputs are typed
const { actions: offered } = await actions.manifest()Import the catalogue as a type only: the client entry imports no server code, Vue, H3 or AI SDK. Options: baseUrl, route (default /api/actions), headers (an object or a function read per request) and fetch. An injected fetch is (url, init: { method: 'GET' | 'POST'; body?; headers? }) => Promise<decoded body> that rejects on non-2xx, exactly like $fetch, so its errors reach you untouched. The default uses the global fetch, sends JSON, decodes the response and throws an ActionsClientError for non-2xx with statusCode, statusMessage and data (the decoded H3 error body, so schema issues are error.data.data.issues, as with $fetch). The action id is encoded as one path segment.
CLI
forge-actions (Node >= 20, no dependencies) calls a deployed app's user actions:
export FORGE_ACTIONS_URL=https://app.example.com FORGE_ACTIONS_TOKEN=… # or --url / --token
npx forge-actions list # [{ id, kind, description }]
npx forge-actions trainings.create --help # the action's fields from its manifest
npx forge-actions trainings.create --title Abend --seats 12 # primitive flags, coerced by the schema type
npx forge-actions trainings.create --json '{"title":"Abend","tags":["a"]}'--route defaults to /api/actions; the token is sent as Authorization: Bearer and never printed (output is masked). Flags work for string, number, integer and boolean fields (a bare --online is true); object and array fields, and fields named url, token, route, json or help, go through --json, and a flag overrides the same field in --json. Exit codes: 0 success (JSON on stdout); 1 transport failure or a failed action (denied, not found, handler error), message on stderr; 2 invalid usage: unknown flag or action id, missing or uncoercible arguments, or input the server's schema rejected.
Using server actions as AI SDK tools
import { serverActionToolSet } from '@skaile/forge-app-actions/chat/ai-sdk'
const tools = serverActionToolSet(forAudience(SERVERAKTIONEN, 'llm'), { principal, event, deps })
await streamText({ model, tools, … }) // or hand them to createAiSdkChatHandlerEach action becomes a tool named toolNameOf(action.name) (as in MCP) with its description and JSON input schema, in a null-prototype map. Calls go through invokeAction with the scope you gave: input is parsed and the action's authorize runs inside run for every call, so a grant removed from your store between two calls of the same tool set denies the second. Do not rely on scope for fresh authority; it is captured. A failed call throws an Error with the outcome's message, which the AI SDK reports to the model as a tool error. Two different action names that map to one tool name throw at construction, naming both. Narrow with forAudience(…, 'llm') yourself. Peer: ai ^7.0.124 (optional).
