Server Actions

Server Actions

Server Actions

Plugin DeveloperPlugin Developer Agent Kit — documentation written for AI agents, readable by humans.

Actions provide RPC-style server endpoints for client-to-server communication.

makeJayAction — Mutations (POST)

import { makeJayAction } from '@jay-framework/fullstack-component';

export const addToCart = makeJayAction('cart.addToCart')
  .withServices(CART_SERVICE)
  .withHandler(async (input: { productId: string; quantity: number }, cartService) => {
    const cart = await cartService.addItem(input.productId, input.quantity);
    return { cartItemCount: cart.items.length };
  });

makeJayQuery — Reads (GET)

Queries use GET and support caching:

import { makeJayQuery } from '@jay-framework/fullstack-component';

export const searchProducts = makeJayQuery('products.search')
  .withServices(PRODUCTS_SERVICE)
  .withCaching({ maxAge: 60 })
  .withHandler(async (input: { query: string; page?: number }, productsDb) => {
    const results = await productsDb.search(input.query, input.page);
    return { products: results.items, totalCount: results.total };
  });

Builder API

makeJayAction('name')
  .withServices(SERVICE1, SERVICE2) // Inject services
  .withMethod('PUT') // Override HTTP method (default: POST for actions)
  .withCaching({ maxAge: 60 }) // Enable caching (queries only)
  .withFiles() // Accept file uploads (multipart/form-data)
  .withHandler(async (input, svc1, svc2) => {
    // Define handler
    return result;
  });

.withFiles() — File Uploads

Actions can receive binary files (images, documents, etc.) via multipart/form-data. Add .withFiles() to the builder chain to enable file uploads.

import { makeJayAction, type JayFile } from '@jay-framework/fullstack-component';
import fs from 'fs';

export const uploadPhoto = makeJayAction('photos.upload')
  .withFiles()
  .withHandler(async (input: { caption: string; photo: JayFile }) => {
    // JayFile provides: name, type, size, path (temp file on disk)
    const data = fs.readFileSync(input.photo.path);
    // Process file...
    return { fileName: input.photo.name, size: input.photo.size };
    // Temp file is automatically cleaned up after handler returns
  });

JayFile type

interface JayFile {
  name: string; // Original filename
  type: string; // MIME type (e.g., 'image/png')
  size: number; // File size in bytes
  path: string; // Absolute path to temp file on disk
}

Multiple files

Use an array type for the field — files with the same field name are grouped:

.withHandler(async (input: { images: JayFile[] }) => {
    for (const img of input.images) {
      // Process each file
    }
});

File fields must be top-level properties — not nested inside objects. Dynamic file fields via index signatures are supported:

{ notes: string; screenshot: JayFile; extras: JayFile[] }           // OK
{ notes: string; [key: string]: string | JayFile | undefined }      // OK
{ meta: { photo: JayFile } }                                        // NOT supported

Streaming with files

makeJayStream also supports .withFiles():

export const processImages = makeJayStream('images.process')
  .withFiles()
  .withHandler(async function* (input: { images: JayFile[] }) {
    for (const img of input.images) {
      yield { step: 'processing', fileName: img.name };
    }
    yield { step: 'done' };
  });

Client-side usage

The client automatically sends FormData when File or Blob objects are present:

refs.uploadBtn.onClick(async () => {
  const fileInput = refs.fileInput.element as HTMLInputElement;
  const file = fileInput.files?.[0];
  const result = await uploadPhoto({ caption: 'My photo', photo: file });
});

ActionError

Throw typed errors from action handlers:

import { ActionError } from '@jay-framework/fullstack-component';

throw new ActionError('OUT_OF_STOCK', 'Only 2 units available');
throw new ActionError('INVALID_INPUT', 'Product ID is required');

Calling Actions from Client

Actions are callable functions on the client:

.withInteractive(function MyComp(props, refs) {
    refs.addToCart.onClick(async () => {
        const result = await addToCart({
            productId: props.productId,
            quantity: 1,
        });
        // result.cartItemCount
    });
})

Calling Actions from Server

When called from server-side code (e.g., within a render phase), services are automatically injected — no network call is made.

.jay-action Metadata Files

Each action should have a .jay-action file describing its input/output schemas for agent discovery:

name: searchProducts
description: Search products with text, filters, sorting, and pagination

import:
  productCard: product-card.jay-contract

inputSchema:
  query: string
  filters?:
    inStockOnly?: boolean
    minPrice?: number
    maxPrice?: number
  sortBy?: enum(relevance | price_asc | price_desc)
  pageSize?: number

outputSchema:
  products:
    - productCard
  totalCount: number
  hasMore: boolean

Jay-Type Notation for Schemas

Notation Meaning
prop: string Required string
prop?: number Optional number
prop: boolean Required boolean
prop: file File upload (JayFile)
prop: file[] Multiple file uploads
prop: enum(a | b | c) Required enum
prop: + nested block Nested object
prop: + - child: type Array of objects
prop: record(T) Record with typed values
prop: importedName Type from import: block

makeJayStream — Streaming (POST, NDJSON)

Streaming actions return an async generator that yields chunks:

import { makeJayStream } from '@jay-framework/fullstack-component';

export const discoverParams = makeJayStream('routes.discoverParams')
  .withServices(PRODUCTS_SERVICE)
  .withHandler(async function* (input: { route: string }, productsService) {
    let page = 1;
    while (true) {
      const products = await productsService.list({ page, pageSize: 100 });
      yield products.map((p) => ({ slug: p.slug }));
      if (!products.hasMore) break;
      page++;
    }
  });

Consuming on the client

for await (const batch of discoverParams({ route: '/products/[slug]' })) {
  console.log(batch); // [{ slug: 'item-a' }, { slug: 'item-b' }]
}

Wire format

The server responds with NDJSON (newline-delimited JSON). Each line is a complete JSON object:

{"chunk":[{"slug":"item-a"},{"slug":"item-b"}]}
{"chunk":[{"slug":"item-c"}]}
{"done":true}

.jay-action for streaming

Add streaming: true to the metadata file:

name: discoverParams
description: Discover URL params by querying the product catalog
streaming: true
inputSchema:
  route: string
outputSchema:
  - slug: string

.jay-action for file uploads

Use file type for upload fields. The generated TypeScript uses JayFile:

name: uploadPhoto
description: Upload a product photo with caption
inputSchema:
  caption: string
  photo: file
  attachments?: file[]
outputSchema:
  fileId: string
  message: string

For dynamic file fields, use record(file):

name: submitTask
description: Submit task with named file attachments
inputSchema:
  notes: string
  files: record(file)

This generates files: Record<string, JayFile>.

Type Helpers

import {
  ActionInput,
  ActionOutput,
  isJayAction,
  StreamChunk,
  isJayStreamAction,
} from '@jay-framework/fullstack-component';

type SearchInput = ActionInput<typeof searchProducts>;
type SearchOutput = ActionOutput<typeof searchProducts>;
type ParamBatch = StreamChunk<typeof discoverParams>;

About this document

This page is part of the Jay Stack Agent Kit — documentation generated from the framework source and written primarily for AI agents. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation. Learn more about the Agent Kit →