model.signal.ts

model.signal.ts is the door to a module. It decides what a client can call, which lists a page can load, and what the server runs on its own.
You open it when a page needs a new call or list, or the server needs a scheduled job. The logic stays in the service; handlers here only call it.
StoryInternalinternal()
Work the server runs by itself: computed fields, schedules, lifecycle hooks, queue jobs.
StorySliceslice()
Lists a page loads, like inRoot. Each one becomes fetch methods and store state.
StoryEndpointendpoint()
Calls a client makes: queries, mutations, websocket messages and pubsub rooms.
Words used on this page
guard
A class that decides if a call may run: Public, Every (any signed-in account), Admin.
internal argument
A value the server fills in, not the caller, such as the signed-in user from .with(Self).
refName
The model's camelCase name, like story. Routes and fetch method names are built from it.
MCP
The protocol AI agents use to call your endpoints. Akan serves it at /mcp.
The skeleton
Every signal file declares the three classes in this order, even when one is empty:
apps/blog/lib/story/story.signal.ts
  • this holds the services. Inside exec, this.storyService is the module's service, and srv.story.with(srv.actionLog) adds this.actionLogService.
  • exec is one line. It calls one service method and returns the result; loading and deciding happen in the service.
  • Barrels come in as values. A signal imports * as cnst and * as srv with a plain import, not import type.

Extending A Library Model

An app can add its own user module on top of the one in libs/shared. Pass the library's classes as the last arguments and write only what your app adds.
../__lib/lib.signal exports the library's classes for each model:
apps/blog/lib/user/user.signal.ts
  • Spread them last. internal(), slice() and endpoint() each take any number of library classes after the builder.
  • The library wins a name clash. Writing a key the library already declares does not replace it, so give yours a new name.
  • Their services come along. The library's services are on this beside yours.

Defining Internal Tasks

internal() holds work no client calls. The server runs it on a schedule, at startup or shutdown, for a queued job, or when a computed field is read.
resolveField(Type)
Computes a resolve field of the constant. exec gets the parent document first.
interval(ms)
Runs every ms milliseconds.
cron(expression)
Runs on a cron schedule, such as every midnight.
timeout(ms)
Runs once, ms milliseconds after the server starts.
initialize(options?)destroy(options?)
Runs when the server process starts or stops.
process(Type)
A background queue job. .msg() declares its payload, and a service enqueues it.
A computed like count and a nightly cleanup look like this:
apps/blog/lib/story/story.signal.ts
  • The constant declares the field. like must exist in the model's via(…, (resolve) => ({ like: resolve(Int) })).
  • Schedules return nothing. Only resolveField and process handlers return a value; the others return void.
  • A service enqueues a process job. It injects storySignal: signal<sig.Story>() and calls this.storySignal.archive(storyId).
Schedule options
Every builder except resolveField takes these in its last argument:
serverMode"federation" | "batch" | "all"default "all"
Which server roles run it. "batch" runs on batch and "all" servers, never on federation.
operationMode("cloud" | "edge" | "local")[]default every mode
Runs only where AKAN_PUBLIC_OPERATION_MODE is in the list, like ["cloud"].
lockbooleandefault true
interval and cron skip a run while the previous one is still running in this process.
enabledbooleandefault true
false turns the job off without deleting its code.

Defining APIs With endpoint()

endpoint() holds what a client can call. Choose the kind by what the call does, then describe each argument with a builder.
Four kinds
Kind
HTTP
WebSocket
Request and answer
query(Type, options?)
✓
Reads data with a GET. The client awaits the answer.
mutation(Type, options?)
✓
Writes data or runs a business action with a POST.
Realtime
message(Type, options?)
✓
One message a client sends over the socket. .msg() declares its fields.
pubsub(Type, options?)
✓
A room clients subscribe to and the server publishes into. .room() names it.
✓Travels over thisNot this
Argument builders
Each builder says where one argument comes from. exec receives them in the order you declare them, then the .with() values:
.param(name, Type)
A required URL path segment. One scalar or enumOf, never a model or an array.
.search(name, Type)
A query-string value. Always optional, so exec may receive undefined.
.body(name, Type, options?)
A request-body value of a mutation. A query is sent without a body, so give it .search() instead. { nullable: true } makes it optional.
.msg(name, Type, options?)
A payload field of a message or of a process job.
.room(name, Type)
A key that names the pubsub room a client joins.
.with(InternalArg, options?)
A server-supplied value: Self, Me, Req, Res, Ws, Ip, or your own. Missing means 401.
Optional arguments go last: a required .param, .msg or .room cannot come after a .search or a nullable argument.
Query and mutation
A read anyone may make, and a write only a signed-in account may make:
apps/blog/lib/story/story.signal.ts
  • Pick a name no generated API uses. Every story module already has story and createStory, so a custom endpoint needs its own name, like storyBySlug.
  • Take the caller from .with(Self). Never trust a user id the client sends; the service checks ownership again.
Message and pubsub
A websocket message, and the room that tells everyone in it about a new chat:
apps/blog/lib/chatRoom/chatRoom.signal.ts
  • The service publishes. It injects chatRoomSignal: signal<sig.ChatRoom>() and calls this.chatRoomSignal.chatAdded(roomId, chat). A pubsub's own exec runs when a client subscribes.
  • Guards go on the endpoint itself. A slice's guards map never reaches a message or a pubsub, so without its own guards anyone can send or subscribe.
  • Rooms are re-checked. A message runs its guards on every send. A subscribed room runs them again when the socket's credential changes, and drops the subscription if they fail.
Serving a fixed path
Some files must live at a fixed address, like /sitemap.xml. The path, prefix and globalPrefix options move an endpoint there:
apps/blog/lib/story/story.signal.ts
  • Where it lands. prefix: false drops the /story segment and globalPrefix: false the API prefix, so it answers at /sitemap.xml.
  • Return a Response to set your own body and headers. It is sent as it is.
Calling them from the client
Each endpoint becomes a fetch method named after its key. A page awaits queries directly; in the browser, call them from a store action.
storyBySlug: query(…)
Resolves to the Story.
publishStory: mutation(…)
Resolves to the published Story. .with(Self) is not a client argument.
readChat: message(…)
Returns nothing; it only sends. fetch.listenReadChat(fn) receives the replies.
chatAdded: pubsub(…)
Returns a function that unsubscribes. fn runs on every publish.

The Options Object

The second argument of query, mutation, message and pubsub is the same options object. Most of it decides what happens before your handler runs.
What runs before exec
fetch.publishStory(storyId)
Loggingerrors, and every call at debug
Timeoutthe endpoint's own timeout ms
AccountMiddlewareand any the app registered
guardsin declaration order
Internal arguments.with(Self) · .with(Me)
cache lookupa query with no internal argument only
exec() handler
resolveReturnhidden and secret fields masked
403 Forbidden
gatewayTimeoutthe handler keeps running
stored resultonly after the guards passed
  • Nothing to pay until declared. Logging and Timeout are always registered, but Timeout steps aside for an endpoint with no timeout, and the cache lookup for one with no cache.
  • A timeout answers, it does not cancel. The caller gets base.error.gatewayTimeout, while the handler still runs to the end.
Access and caching
guardsGuardCls[]default none
Run in order after every middleware; the first refusal answers 403. Without it, nothing is checked.
mcpbooleandefault true
false keeps it away from AI agents. Guards and HTTP stay exactly the same.
timeoutnumber (ms)default client's 30 s
Past it the caller gets base.error.gatewayTimeout. The client waits the same budget.
cachenumber (ms)default not cachedquery
Reuses the answer this long. Only for a query with no .with(); looked up after the guards pass.
nullablebooleandefault false
Allows a null return. Without it, a handler that returns null fails.
middlewaresMiddlewareCls[]default none
Extra middleware for this endpoint only, run after the registered chain.
Routing and transport
method"POST" | "PATCH" | "PUT" | "DELETE"default "POST"mutation
The HTTP verb of a mutation. Change it only when a foreign protocol requires another.
pathstringdefault the endpoint key
A fixed route instead of the key. A trailing * matches the rest of the path.
prefixfalse | stringdefault the model refName
Replaces the model segment in front of the path, or drops it with false.
globalPrefixfalsedefault the API prefix
false drops the API prefix too. With prefix: false, the route sits at the site root.
fileUploadbooleandefault falsemutation
Marks the mutation the generated upload action calls. The shared file module already has one.
backpressure"coalesce" | "queue"default "coalesce"pubsub(Binary)
When a subscriber falls behind: keep only the newest frame, or queue every frame.

What An Argument May Be

Every argument builder takes the same four kinds of type:
KindExample
↳ Note
ScalarID · String · Int · Float · Boolean · Date
From akanjs/base; String, Boolean and Date are the JS globals.
Modelcnst.StoryInput
A class from the module's constant, usually the Input.
enumOfcnst.StoryStatus
A value outside its list is refused.
Array[ID] · [cnst.StoryInput]
Any of the above in [ ]. Not allowed in .param.
Three mistakes are worth knowing up front, because two of them are not type errors:
Int or Float, never Number
.body("count", Int)
A count is Int and a price is Float. Number does not typecheck.
Upload is a body, never a field
.body("files", [Upload])
An Upload body switches the request to multipart, and the mutation that owns uploads declares fileUpload: true. A model points at the File model instead.
Bytes are Binary, never Any
.body("frame", Binary)
Binary is a Uint8Array on both sides and accepts base64, so it fits JSON and websocket frames. Any turns a Buffer into a { type, data } object that never comes back: 3.6x the size, and it only breaks at the first byte read.

Generated Model APIs

Every database module gets these fetch methods without an endpoint. Write a custom endpoint only when a business action needs its own name.
The slice's guards map protects them: get guards the reads, cru the writes.
Generated method
get
cru
Read
<model>(id)
✓
Loads the full model.
light<Model>(id)
✓
Loads the Light model.
view<Model>(id)
✓
Data for a detail page. Destructure it for one promise per field, or await it whole.
edit<Model>(id)
✓
Data for an edit form, shaped like view<Model>. Exists only with a create, update or remove guard.
Write
create<Model>(data)
✓
Creates one from an input.
update<Model>(id, data)
✓
Updates one by id.
merge<Model>(modelOrId, data)
✓
Calls update<Model> with only the fields you pass. Takes the model or its id.
remove<Model>(id)
✓
Removes one. Removal is always soft.
✓Guarded by this keyNot this key
A detail page hands the unawaited view to its Zone:
apps/blog/page/story/[storyId]/_index.tsx
  • Destructure to stream, await to wait. fetch.viewStory(id) hands out story and storyView as separate promises; await gives both at once.
  • merge patches. In a store action, await fetch.mergeStory(story, { title }) sends only title.
  • Override one write with create, update or remove. Each replaces cru for that one method, as libs/shared does with create: Admin for users.

Slices: Lists For Pages

slice() declares the lists pages show. Each entry starts with init(), takes .param(), .search() and .with() arguments like an endpoint, and returns a service query. .body() is deprecated there: a list is loaded with no request body, so its value never arrives.
The stories under one root, readable by anyone:
apps/blog/lib/story/story.signal.ts
  • root is always Admin. It guards the root slice, init<Model>(queryKey, args), which can run any filter the model declares.
  • A named slice names its own guards in init({ guards: [...] }). The map's get and cru never reach it.
  • Return the query, do not shape it. exec returns a query descriptor that takes no .sort() or .limit(); order and page size are fetch options.
Generated fetch methods
Each slice key becomes the Suffix of these methods, so inRoot gives storyListInRoot, initStoryInRoot and the rest:
<model>List<Suffix>(...args, skip, limit, sort)
One page of the list.
<model>Insight<Suffix>(...args)
The aggregate numbers for the same query.
init<Model><Suffix>(...args, option?)
List and insight together, as one promise per field. Hand storyInitInRoot to a Zone.
get<Model>Init<Suffix>(...args, option?)
The same init data as one awaited object.
init<Model>(queryKey?, args?)
The root slice. queryKey names a model filter (none means any), args its arguments.
Order and page size go in the last option: fetch.initStoryInRoot(rootId, { sort: "latest", limit: 20 }).
Using it in a page
The page starts both queries and hands each result to the part that needs it:
apps/blog/page/root/[rootId]/_index.tsx
  • storyInitInRoot goes to a Zone, which fills the store from it.
  • storyListInRoot stays on the server. It holds model instances, which a client component cannot take as props, so read it in a server component or a Load.Stream.
  • Nothing is awaited. Both queries start at once, and each section renders when its own promise lands.

Rules To Remember

Four rules cover most mistakes in a signal file:
  • Extend, do not copy. When a library already has the model, spread ...user.internals, ...user.slices and ...user.endpoints instead of re-declaring them.
  • Reach other services with .with(). srv.story.with(srv.actionLog) puts this.actionLogService in every handler.
  • Guards decide what AI agents see. There is no opt-in: mcp: false only removes an already-guarded endpoint, and a slice's mcp: { cru: false } mirrors its guards map for the root slice and generated CRUD.
  • Prompts live on pages. endpoint() has no prompt builder; a screen is published as an MCP prompt with page().prompt(name, description).
What reaches an AI agent
What you declare
Client
AI agent
Published to agents
query · guards: [Public]
✓
✓
Any guard is a decision, Public included, so a read publishes.
mutation · guards: [Every]
✓
✓
A write with a real guard publishes.
Served, but hidden from agents
no guards
✓
Anyone can call it, and no agent can see it.
mutation · guards: [Public]
✓
Public alone on a write counts as no guard.
mcp: false
✓
Taken off the agent shelf on purpose. Guards are unchanged.
guards: [Every, Person]
✓
Person reserves the act for a human.
message · pubsub
✓
They ride the websocket, which an MCP call does not have.
Any · Binary · Upload
✓
A return typed Any or Binary, or a file upload, cannot be described to a model.
✓Can call itCannot see it

Released under the MIT License

Connect your AI to these docs

MCPhttps://akanjs.com/mcp
Copyright © 2026 Akan.js All rights reserved.System managed bybassman