npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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) and useCapabilityInvocations (handle capability_invoked from 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')
  • run parses the input exactly once, derives name = app.<id> (a name in the caller's context is ignored), awaits authorize(parsed, context) and only then calls the handler. A throwing authorize denies the call on every path, direct .run included; a malformed input never reaches authorize or the handler. Without authorize nothing changes.
  • The core has no principal, role or grant vocabulary: ctx.principal and ctx.deps are whatever the transport's authorize hook set (P/D default to unknown). Authorization is the app's; read the current identity and grants inside authorize, 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 an ActionOutcome, { ok: true, result } or { ok: false, status, message, issues? }: the action's own input failing its schema is 400 (invalid input: …, with issues), an error with a numeric statusCode keeps it and its message, anything else (a ZodError from authorize or the handler included) is 500 with the message Internal error, the original going to console.error. It calls action.run only 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 the user-audience actions, with cache-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:authorize receives { 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 sets allowed. principal and deps become ctx.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 with data.issues, an unknown action or one without the user audience 404, a wrong method 405, a denial by the action's authorize its own statusCode (403 for a grant check), an unexpected error 500 with the message Internal error.
  • To serve the same API from another stack (Express, plain node:http), @skaile/forge-app-actions/http exports createActionsHttpHandler({ actions, route, authorize }), a (req, res, input?) handler. authorize receives the same decision (event is the request unless input.event is given); pass input.readBody when 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 createAiSdkChatHandler

Each 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).