Alokai
Alokai Compass

Script actions and transitions

Some steps in a workflow need no LLM. Removing a greeting before the assistant answers, adding a note about the logged-in customer, or picking the next action from the workflow payload are all plain logic. An LLM call for them costs time and tokens, and its result can vary between runs.

A script action runs a function in place of an LLM. A scripted transition picks the next action with a function in place of the LLM router. Both are written in the workflow configuration, next to tools, and follow the same pattern as a tool callback.

Script actions

A script action reads the conversation and returns it as the next actions should see it. Its messages reach the storefront the same way as tool results. In a streaming request, the customer sees only what the script sends with sendToBrowser(). A request without streaming returns every message added during the run.

Adding a script action

  1. Add an action with type: 'script' to the workflow's actions.
  2. Write its callback. The callback receives the request context and an object with workflowPayload, messages and scratchpad.
  3. Return the new conversation in messages.
  4. Connect the action in transitions like any other action.

The following workflow removes a greeting and tells the assistant that the customer is logged in:

import { defineWorkflow, END, START } from '@alokai/compass/server';
import { utils } from '@/sf-modules/compass/utils';

const assistantWorkflow = defineWorkflow({
  actions: {
    prepareConversation: {
      type: 'script',
      callback: async (context, { messages }) => ({
        messages: [
          ...messages.filter((message) => message.content !== 'Hello!'),
          utils.createSystemMessage('The customer is logged in.'),
        ],
      }),
    },
    assistant: {/* action configuration */},
  },
  transitions: {
    [START]: 'prepareConversation',
    ['prepareConversation']: 'assistant',
    ['assistant']: END,
  },
});

A callback that returns nothing leaves the conversation unchanged. To rely on fields in the workflow payload, declare them in payloadSchema, the same way as for a tool. The callback then receives the parsed, typed payload. A script action can also call other integrations through context.getApiClient(), the same way a tool does.

Defining a step in its own file

To keep a script action or a scripted transition out of the workflow file, define it with defineScriptAction() or defineScriptedTransition() and import it into the workflow. The step's payloadSchema and scratchpadSchema type its callback, as they do inline. The file can be anywhere. The module's query-rewriting demo keeps everything in sf-modules/compass/query-rewriting-search/, with script actions in actions/, scripted transitions in transitions/ and tools in tools/.

// sf-modules/compass/query-rewriting-search/actions/redirectToCategory.ts
import { defineScriptAction } from '@alokai/compass/server';
import { z } from 'zod';

export const redirectToCategory = defineScriptAction({
  payloadSchema: z.object({ query: z.string() }),
  callback: async (context, { messages, workflowPayload }) => {
    // workflowPayload.query is a string
  },
});
// workflow file
actions: {
  redirectToCategory,
},

Returning the conversation

The returned messages array replaces the whole conversation. The actions after the script see exactly that array. A message left out of it is removed, and a new message is added. A returned message without an id gets one.

The callback receives a copy of the conversation. Changing that copy has no effect until the callback returns it.

The new conversation lasts for the current request only. The conversation history that the storefront sends with the next request is not changed. A request without streaming returns only messages whose id was not in the request.

Keep tool calls and their results together

An assistant message with tool_calls and the tool messages that answer it belong together. The LLM provider rejects a conversation that holds a tool result without its call, so the next LLM action fails with status code 400. Remove or keep them as a group. A filter on content can drop such an assistant message by accident, because its content is often empty.

Messages

The messages argument holds the whole conversation as the workflow stores it. Content that the workflow's transformMessageContent trims for the LLM is not trimmed here.

Content that is valid JSON arrives parsed, as the storefront SDK parses it. A tool result that returned a generative UI component therefore holds the component object, not its JSON text, and a user message 42 holds the number 42. Other text arrives unchanged. When the script returns the conversation, content it did not change goes back exactly as it was, even if the script copied it. Changed content is turned into a JSON string, because LLM providers accept only text; only an assistant message may keep an object, as a structured output does.

CompassMessage types the parsed content, so it is not LangChain's BaseMessage type. To pass messages to a LangChain function that expects BaseMessage, cast them: messages as unknown as BaseMessage[].

The utils object

Every helper a script needs is on one object, utils. The module creates it once in sf-modules/compass/utils.ts, from its response registries:

import { createCompassUtils } from '@alokai/compass/server';
import { UI_COMPONENTS } from '@/sf-modules/compass/responses/dynamic-ui';
import { FRONTEND_HINTS } from '@/sf-modules/compass/responses/frontend-hints';
import { STATE_PATCHES } from '@/sf-modules/compass/responses/state-patches';
import { STRUCTURED_OUTPUTS } from '@/sf-modules/compass/responses/structured-outputs';

export const utils = createCompassUtils({ FRONTEND_HINTS, STATE_PATCHES, STRUCTURED_OUTPUTS, UI_COMPONENTS });

The names and arguments are the same as sdk.compass.utils in the storefront. A script never passes a registry.

MessageCreate withCheck withFields besides id and content
Customer messageutils.createUserMessage(content)utils.isUserMessage(message)none
Assistant messageutils.createAssistantMessage(content)utils.isAssistantMessage(message)tool_calls: the tools the LLM called
Instruction to the LLMutils.createSystemMessage(content)utils.isSystemMessage(message)none
Tool resultutils.createToolMessage(content, toolCallId)utils.isToolMessage(message)tool_call_id: the id of the tool call it answers

An assistant message can carry a response the storefront renders, the same as a tool result. Each create function checks the props against the module's registry and throws when they do not match. Each is function returns true only when the message holds that response and its props pass the registry's schema, and then narrows the message's content to it.

ResponseCreate withCheck withRegistry in the module
Generative UI componentutils.createUiComponent(id, props)utils.isUiComponent(message, id?)responses/dynamic-ui
Frontend hintutils.createFrontendHint(id, props)utils.isFrontendHint(message, id?)responses/frontend-hints
Structured outpututils.createStructuredOutput(id, props)utils.isStructuredOutput(message, id?)responses/structured-outputs
State patchutils.createStatePatch(stateId, stepId, patch)utils.isStatePatch(message, stateId?, stepId?)responses/state-patches

A state patch is sent as a frontend hint, but isFrontendHint returns false for it, because state-patch is not in the frontend-hints registry. Use isStatePatch for state patches.

What reaches the storefront

RequestThe storefront receivesIt does not receive
Without streamingEvery message added during the run. The SDK turns each into an assistant message and parses JSON-string content.Anything sent with sendToBrowser().
StreamingLLM text as it is generated, and everything sent with sendToBrowser().Messages a script or a structured-output action adds to the conversation.

To reach both kinds of request, add the message to the conversation and send it with sendToBrowser().

Examples

All examples import utils from @/sf-modules/compass/utils.

Add follow-up suggestions to the conversation as a generative UI component:

callback: async (context, { messages }) => ({
  messages: [
    ...messages,
    utils.createAssistantMessage(
      utils.createUiComponent('follow-up-suggestions', {
        suggestions: [{ id: 'size-guide', label: 'Show me the size guide' }],
      }),
    ),
  ],
}),

Tell the storefront to refresh the cart, the same way the cart tools do:

import { sendToBrowser } from '@alokai/compass/server';

callback: async () => {
  sendToBrowser(utils.createFrontendHint('cart-has-been-updated', {}));
},

Answer a query classification without the LLM, as a structured output:

callback: async (context, { messages, workflowPayload }) => ({
  messages: [
    ...messages,
    utils.createAssistantMessage(
      utils.createStructuredOutput('query-classification', {
        type: workflowPayload.query.includes(' ') ? 'natural-sentence' : 'keywords',
      }),
    ),
  ],
}),

Read the structured output an earlier LLM action returned:

callback: async (context, { messages }) => {
  const classification = messages.find((message) => utils.isStructuredOutput(message, 'query-classification'));
  return { scratchpad: { type: classification?.content.props.type } };
},

Read the cart a tool returned. Tool results are checked the same way:

const cart = messages.find((message) => utils.isUiComponent(message, 'cart'));

Update the guided selling filters in the storefront with a state patch:

callback: async () => {
  sendToBrowser(utils.createStatePatch('guided-selling', 'search', { color: 'red' }));
},

Remove the assistant messages that called a tool, together with every result they received, so the conversation stays valid:

callback: async (context, { messages }) => {
  const callsCart = (message) =>
    utils.isAssistantMessage(message) && (message.tool_calls ?? []).some((call) => call.name === 'getCart');
  // A message can hold several calls, so drop the results of all of them.
  const droppedCallIds = new Set(
    messages.filter(callsCart).flatMap((message) => message.tool_calls.map((call) => call.id)),
  );
  return {
    messages: messages.filter(
      (message) => !callsCart(message) && !(utils.isToolMessage(message) && droppedCallIds.has(message.tool_call_id)),
    ),
  };
},

Errors

An error thrown in the callback ends the request. A streaming request ends with an error event with status code 500. The error is written to the middleware log.

Scripted transitions

A scripted transition decides which action runs next. It lists the actions it may go to, and its callback returns one of them. Unlike a choice transition, it gives the same answer for the same input on every run.

Adding a scripted transition

  1. In transitions, set the entry for the source action to an object with type: 'scripted'.
  2. List every possible next action in to. END is allowed.
  3. Write its callback. The callback receives the request context and an object with workflowPayload, messages and scratchpad, and returns { to } with one of the entries in to.

The following workflow starts with a product review when the storefront sends a product in the payload, and with the assistant otherwise:

import { defineWorkflow, END, START } from '@alokai/compass';
import { z } from 'zod';

const assistantWorkflow = defineWorkflow({
  actions: {
    reviewProduct: {/* action configuration */},
    assistant: {/* action configuration */},
  },
  transitions: {
    [START]: {
      type: 'scripted',
      to: ['reviewProduct', 'assistant'],
      payloadSchema: z.object({ product: z.object({ id: z.string() }).optional() }),
      callback: async (context, { workflowPayload }) => ({
        to: workflowPayload.product ? 'reviewProduct' : 'assistant',
      }),
    },
    ['reviewProduct']: END,
    ['assistant']: END,
  },
});

A scripted transition only chooses the next action. To change the conversation as well, add a script action before it. Several transitions can lead to the same action, and one callback can be shared by several transitions.

Errors

An entry in to that is not an action in the workflow stops the workflow from being built. Every request to that workflow then fails with an error that names the entry. A callback that returns a to value not listed in to ends the request with an error, the same way as an error thrown in the callback.

Passing data between steps

A script action or a tool can hand values to the steps after it without adding them to the conversation. The values are kept in scratchpad for the current request only.

  1. Return the values in scratchpad from a script action or a tool callback.
  2. In each step that reads a value, declare the fields it needs in scratchpadSchema.
  3. Read the values from scratchpad in that step's callback.

The following workflow looks up a product once and routes on the result:

import { defineWorkflow, END, START } from '@alokai/compass';
import { z } from 'zod';

const assistantWorkflow = defineWorkflow({
  actions: {
    lookupProduct: {
      type: 'script',
      payloadSchema: z.object({ query: z.string() }),
      callback: async (context, { workflowPayload }) => ({
        scratchpad: { productId: await findProductId(context, workflowPayload.query) },
      }),
    },
    reviewProduct: {/* action configuration */},
    assistant: {/* action configuration */},
  },
  transitions: {
    [START]: 'lookupProduct',
    ['lookupProduct']: {
      type: 'scripted',
      to: ['reviewProduct', 'assistant'],
      scratchpadSchema: z.object({ productId: z.string().nullable() }),
      callback: async (context, { scratchpad }) => ({ to: scratchpad.productId ? 'reviewProduct' : 'assistant' }),
    },
    ['reviewProduct']: END,
    ['assistant']: END,
  },
});

Before a step runs, its scratchpad is checked against its scratchpadSchema. A missing or invalid field ends the request with an error that names the step and the field. A step without scratchpadSchema receives scratchpad as Record<string, unknown>.

Tools and the scratchpad

Script actions and tools write scratchpad. Scripted transitions only read it. A tool declares the fields it reads in its own scratchpadSchema, the same way as payloadSchema. The tool's schema, callback and cache key functions receive scratchpad checked against that schema. shouldEnableTool receives it unchecked, so it can turn the tool off when the fields the tool needs are missing. Include the fields the schema depends on in the cache key, or a cached schema is reused for different values.

A tool writes to scratchpad by returning it from its callback, next to toolResponse:

async (context, { aiProvidedArgs }) => ({
  toolResponse: 'Filters applied.',
  scratchpad: { appliedFilters: aiProvidedArgs.filters },
})

Values from several writers are merged. A later write to the same field replaces the earlier value.

Reference

Script action

OptionTypeDefaultDescription
type'script'requiredMarks the action as a script action.
payloadSchemaZod schemanoneChecks workflowPayload before the callback runs. A mismatch ends the request with an error naming the action.
scratchpadSchemaZod schemanoneFields of scratchpad the action reads. Checked before the callback runs.
callback(context, { workflowPayload, messages, scratchpad }) => Promise<{ messages?: CompassMessage[]; scratchpad?: Record<string, unknown> } | void>requiredFunction that runs in place of an LLM. A returned messages array replaces the conversation.

Scripted transition

OptionTypeDefaultDescription
type'scripted'requiredMarks the transition as scripted.
tostring[]requiredActions the transition may go to. May include END.
payloadSchemaZod schemanoneChecks workflowPayload before the callback runs. A mismatch ends the request with an error naming the transition.
scratchpadSchemaZod schemanoneFields of scratchpad the transition reads. Checked before the callback runs.
callback(context, { workflowPayload, messages, scratchpad }) => Promise<{ to: string }>requiredReturns the next action in to. The value must be one of the transition's to entries.

Helpers

FunctionReturnsDescription
defineScriptAction(config)Script actionDefines a script action outside the workflow. config takes the options of a script action without type.
defineScriptedTransition(config)Scripted transitionDefines a scripted transition outside the workflow. config takes the options of a scripted transition without type.

Tool additions

OptionTypeDefaultDescription
scratchpadSchemaZod schemanoneFields of scratchpad the tool reads in schema, callback and the cache key. shouldEnableTool receives scratchpad unchecked.
scratchpad in the callback's return valueRecord<string, unknown>noneValues the tool writes, returned next to toolResponse and instructions.

Callback arguments

ArgumentTypeDescription
contextAgenticContextRequest context. The same object a tool callback receives.
workflowPayloadInferred from payloadSchema, else Record<string, unknown>A copy of the payload sent by the storefront, after payloadSchema parsing. Changing it does not affect later steps.
messagesCompassMessage[]A copy of the whole conversation, not trimmed by transformMessageContent.
scratchpadInferred from scratchpadSchema, else Record<string, unknown>Values written by earlier script actions and tools in this request.

On this page