YouBothAgent▾
You — Business rules and flows you own. Read these yourself.
Both — Know the idea; your agent follows the details.
Agent — Conventions and references your agent follows. Look up as needed.
Workspace▾
App & Library▾
Domain▾
Scalar▾
Server Utility Overview
srvkit/ holds server-only code that services, signals and server jobs call. Keeping it here lets the module files stay focused on business behavior.It is also the one safe door for external libraries: vendor SDKs and low-level server APIs pass through srvkit first.



Module files cannot import a third-party package. In
*.service.ts, *.signal.ts and the other module files, lint accepts only relative paths and workspace packages such as akanjs/*, @apps/* and @libs/*. Even node:crypto is refused, so import it in srvkit and export what the service needs.Which folder?
Code outside
lib/ goes in one of five folders. Choose by what the code touches, not what it is for; the common/ and webkit/ pages open with this same table.FolderDescription
common/
Pure, isomorphic, zero-dependency; imports only sibling common/* and akanjs/base, not Err.
webkit/
Touches window, navigator or the native bridge (akanjs/client/native), or is a React hook.
srvkit/
Touches node:*, Bun, process.env, a secret, or a server SDK.
ui/
Renders JSX or defines a recipe, bound to no model; a model-bound component goes in its module.
plugin/
A build-time or CLI-time AkanPlugin, registered in akan.config.ts.
What Belongs In srvkit/
srvkit/ holds seven kinds of code. Four step into the request path, and three are tools a service calls:
KindDescription
Guard
Decides whether a request may run an endpoint: sign-in, role or ownership checks.
InternalArg
Reads a trusted value, such as the caller's account, and hands it to exec as an argument.
Middleware
Wraps every signal call and attaches server context before the endpoint's guards run.
WebProxy
Runs before a page load is routed, to redirect, rewrite, or add headers.
Server helper
A reusable function for hashing, encryption, file handling, image inspection or tokens.
Adaptor
A singleton adapt() class wrapping storage, queues, email, payment or a vendor API.
Class utility
Legacy: a server-only class, such as an SDK client, injected through option.ts.
Where the request-path four run
A page load and a signal call take different paths, and each piece sits on only one of them:
Piece
Page load
/ko/docs
Signal call
HTTP · WS · MCP
Server level: registered once in option.ts
WebProxy
✓
Redirects, rewrites or adds headers before the page is chosen.
Middleware
✓
Attaches server context, such as the account, before guards run.
Signal level: named on each endpoint or slice
Guard
✓
Allows or refuses the call.
InternalArg
✓
Hands a server-made value to exec as an argument.
✓Runs hereNot here
Server Level: WebProxy And Middleware
Both are registered once in the option chain and apply to every request of their kind. A WebProxy acts on page loads before routing; a Middleware wraps every signal call.
WebProxy
Page load
WebProxyredirect · rewrite · headers
Page render
Page load
WebProxyredirect · rewrite · headers
Page render
A WebProxy is a class with one
use(request) method. This one sends a retired URL to its new page:apps/koyo/srvkit/legacyPageRedirect.ts
What
use() returns decides what happens next:| Return value |
|---|
| ↳ What happens |
| Response |
| Sent as is; later proxies and the page do not run. |
| AkanResponse.redirect(url, status?) |
| A redirect Response; the status defaults to 307. |
| AkanResponse.next({ request }) |
| Continues with the request headers you changed. |
| AkanResponse.rewrite(url) |
| Serves another path while the address bar stays the same. |
| undefined |
| Passes the request on unchanged. |
- Page loads only. API routes, the websocket and
/_akan/*paths never pass through a WebProxy. Static files (a path with an extension) skip it too, unless a matcher names them. - A client-side navigation skips it. A
<Link>to/ko/old-docsor arouter.pushthere is not redirected and renders/ko/old-docsitself, usually a 404; the built-in locale and basePath handling still applies. Link to the new page directly, and keep access control out of proxies: guards on the endpoints, andgetSelf({ unauthorize })in a_layout.tsx. - The locale redirect runs first. The built-in proxies run before yours and turn
/old-docsinto/ko/old-docs, so match the path with its locale segment. - Narrow it with a matcher. In
applyWebProxy({ proxy, matcher }), the matcher is a path prefix, aRegExp, or a function of the request.
Middleware
Signal call
Middlewareattach context
Signal endpointGuard → exec
Signal call
Middlewareattach context
Signal endpointGuard → exec
This Middleware resolves the caller and stores it where guards and InternalArgs read it:
apps/koyo/srvkit/requestUserMiddleware.ts
use(env)runs once per process. It receives the server options; only the function it returns runs on every call.- Branch on transport here, and only here. Middleware writes
accountonto the HTTP request or the socket data, and every guard and InternalArg reads it back withcontext.get("account"). - Mounting libs/shared already does this. Its
AccountMiddlewareturns the JWT intoaccount, so most apps never write their own. refNameis the registration key. Two middlewares with the samerefNamereplace each other. For a single endpoint, use themiddlewaressignal option instead.
Register them in option.ts
Once both are declared in srvkit, register them in the app's or library's option chain:
apps/koyo/lib/option.ts
- Several at once, in order.
applyMiddlewareandapplyWebProxyeach take several classes, and proxies run in the order you list them. - Libraries bring their own. An app runs the middleware and proxies of every library it mounts, and its own
option.tsis applied last.
Signal Level: Guard And InternalArg
Guards and internal args are named per endpoint or slice. A Guard decides whether the call may run; an InternalArg turns trusted server context into an exec argument.
After Middleware prepares the context
Middleware
Guardallow or refuse
InternalArgbuild exec args
Signal exec
Service logic
Middleware
Guardallow or refuse
InternalArgbuild exec args
Signal exec
Service logic
Guard
A Guard is a class with
canPass(context). SignedIn reads only the caller; CanCancelOrder also needs the call's arguments:apps/koyo/srvkit/guards.ts
That difference is what
static scope declares. It has no default, so every guard states it:| scope | Reads |
|---|---|
| ↳ In an MCP listing | |
| "account" | The caller only |
| An MCP listing runs it with no arguments and hides what this caller certainly cannot use. | |
| "resource" | The call's arguments too |
| An MCP listing skips it, so the entry stays visible and is stopped at call time. | |
- Keep
static name. fetch serializes guard names and the API explorer filters on them. - Read the caller with
context.get("account"). Guards also run on websocket calls, and a pubsub room re-runs them whenever the socket's credential changes, so never branch ongetHttpContext(). - Side-effect free, and fail closed. A guard must be safe to re-run. No resource named means
false; a load that throws meanslogger.warn, thenfalse. - Every guard must pass. The
guardsarray is checked in order, and the first refusal answers 403.
InternalArg
An InternalArg reads the request context and hands a server-made value to exec, so business logic gets it without asking the client:
apps/koyo/srvkit/internalArgs.ts
Guards and internal args meet in the signal file. Guards go in the option, and
.with() appends an InternalArg after the declared params:apps/koyo/lib/order/order.signal.ts
- Arguments arrive in order. exec receives the
.param()values first, then each.with()value. nullrefuses the call. An InternalArg that returnsnullanswers 401, unless you write.with(CurrentUserId, { nullable: true })and let exec receivenull.- Never trust a client-supplied id. Take the acting user from an InternalArg, not from a
.param().
Before writing your own, check the ones that already ship:
InternalArgDescription
ReqResIpWs
From
akanjs/signal: raw request, response, caller IP, and the socket with its socketId.AccountSelfMeAgentCall
From
@libs/shared/srvkit: account, signed-in user, admin, and whether a model is calling.Service Logic And External Libraries
When a service needs crypto, an AI SDK, an HTTP client or another server-only package, wrap it in srvkit first. How the service then reaches it depends on what you wrapped:
What you wrappedHow the service gets it
Function helper
Imported straight from the srvkit barrel.
Singleton adaptor
plug(Class) in the service, with nothing in option.ts.Class instance (legacy)
Built in option.ts
.use(), then injected with use<T>().Function helper
A function helper needs no wiring. This one uses
node:crypto, which a service may not import itself:apps/koyo/srvkit/createOrderHash.ts
Class instance: the legacy shape


Recognise this shape; do not copy it forward. The
option.ts + use<T>() pair is kept because existing code is written in it. New adaptors are an adapt() class injected with plug(), as the next slide shows.It takes three files. First, a plain class in srvkit:
apps/koyo/srvkit/emailClient.ts
Next, build one instance in
option.ts under a key:apps/koyo/lib/option.ts
Last, a
use<T>() field with the same name in the service:apps/koyo/lib/order/order.service.ts
- The field name is the key.
use<T>()looks the value up by the service field name, soemailClientmust match the key in.use(). - A function helper skips all of this. The same service imports
createOrderHashstraight from@apps/koyo/srvkit.
Adaptor And plug
An adaptor is a singleton
adapt() class that turns an outside system into a service dependency. Declare it in srvkit and plug() it where it is needed; it registers itself, so option.ts needs no entry.apps/koyo/srvkit/paymentApi.ts
The service plugs it by class:
apps/koyo/lib/order/order.service.ts
The
adapt() builder hands you four injectors; destructure only the ones you use:InjectorDescription
use<T>()
A value option.ts provides under the same key: the legacy path.
env(fn)
A value read from the server options once, when the server starts.
plug(Class)
Another adaptor, or a built-in role such as
StorageAdaptorRole.memory(Type, { of })
A value this adaptor keeps in the cache adaptor;
local: true keeps it in-process instead.- One per process.
adapt()is for singletons; a value object you create per use stays a plain class younew. - Adaptors can plug adaptors, as long as the plugs never form a cycle.
- Logger and lifecycle come built in. Use
this.loggerinstead of a newLogger, and put startup work inoverride async onInit(). - Route remote calls through one
#api()withsignal: AbortSignal.timeout(20_000), so no request hangs forever.
Practical Rules
Where the code goes
- Move noisy server code out. Put server-only helper code in srvkit when a service or signal would otherwise become noisy.
- External libraries enter through srvkit. Wrap them here before any convention file uses them.
- Guard to protect, InternalArg to supply. Guards protect requests; internal args provide context-derived signal arguments.
- adapt and plug for shared systems. Use them when a service needs a reusable external system dependency.
- App or library. App-specific integrations go in the app's srvkit; reusable ones go in a library's srvkit.
Inside a srvkit file
- camelCase file, PascalCase class.
paymentApi.tsexportsPaymentApi. - Server only, in both directions. Client files (
ui/,webkit/,*.store.ts, every.tsx) cannot import srvkit, and srvkit cannot import a store,ui/,webkit/or thestbarrel. - Throw
Err, neverError. ImportErrfrom../lib/dict; an adaptor that catches logs withlogger.errorand returnsnull. - Resolve secrets inside a function. Write
process.env.X ?? options.xin the function that needs it, never at module scope. #privateis the house style here. Its lint ban covers only constant, document, service and store files.