Kinotic
Services

Publishing Services

How to publish TypeScript services in Kinotic using decorators.

Use the @Publish decorator to expose any TypeScript class as a remote service. The namespace you pass to @Publish combined with the class name forms the service identifier that callers use to reach it.

Basic Example

import { Publish, Version } from '@kinotic-ai/core'

@Version('1.0.0')
@Publish('com.example')
class GreetingService {
    hello(name: string): string {
        return `Hello, ${name}!`
    }

    add(a: number, b: number): number {
        return a + b
    }
}

This registers the service as com.example.GreetingService at version 1.0.0, in the zone the client is configured for (see Zones below — an application's services register under its app.<org>.<app> zone). Every public method on the class becomes a remotely callable operation.

Zones

Every service address lives in a zone — the isolation boundary the gateway validates on each call (see CRI Format). An application's services always register inside the application's own zone: the client's zonePrefix is set from static project configuration before services are instantiated, and declared zones are appended to it, so a service can never register outside its application.

import { Kinotic } from '@kinotic-ai/core'
import { appZone } from '@kinotic-ai/management-api'
import config from './.config/kinotic.config'

// In your application's entry point, before any @Publish class is instantiated
Kinotic.zonePrefix = appZone(config.organizationId, config.applicationId)

@Publish classes may be instantiated before or after Kinotic.connect(): a registration made before the connection is up queues and subscribes once it is, and every registration re-subscribes automatically on reconnect — including across an explicit disconnect() / connect() cycle.

@Publish()                       // → srv://app.acme-org.orders-app~OrderService
class OrderService { ... }

@Zone('billing')                 // → srv://app.acme-org.orders-app.billing~InvoiceService
@Publish()
class InvoiceService { ... }

A default zone for every service in the project can also be declared in package.json; a class-level @Zone overrides it:

{ "kinotic": { "zone": "billing" } }
import pkg from './package.json' with { type: 'json' }
Kinotic.defaultZone = pkg.kinotic?.zone ?? null

The gateway rejects any send or subscribe outside the zones the authenticated participant may address, so a wrong or missing zone routes nowhere — it can never reach another application.

Registering on a specific client

@Publish registers every instance with the global Kinotic client. A service hosted on a client you created yourself — a second KinoticSingleton, connected under its own credentials or to another server — is registered through that client's serviceRegistry with a ServiceIdentifier naming the namespace, the service name, and the full zone:

import { KinoticSingleton, ServiceIdentifier } from '@kinotic-ai/core'

const host = new KinoticSingleton()
await host.connect({ server: { host: 'localhost', port: 58505 }, credentials })   // the app server

const identifier = new ServiceIdentifier('com.example', 'GreetingService', 'app.acme-org.orders-app')
host.serviceRegistry.register(identifier, new GreetingService())   // → srv://app.acme-org.orders-app~com.example.GreetingService

// later, when the service should no longer be reachable
host.serviceRegistry.unRegister(identifier)

The zone is the complete zone the service lives in: zonePrefix and defaultZone apply to @Publish classes only. Registering the same identifier twice on one client is a no-op, and unRegister ends any stream the service is still producing with an error to its caller.

The service directory

Every published class under the project's servicesPaths gets a service definition from kinotic generate and kinotic sync, its functions with their parameters and types, written to the ServiceDefinitions module under generatedPath. A project that imports that module has the runtime register each service in the platform's service directory as the service comes online: the services registered before connect when the client connects, and a service registered on a connected client as it registers. Only a service in an application's zone, app.<organizationId>.<applicationId> or a zone under it, is registered, through the platform's ApplicationServiceDirectory in app-api, which refuses a zone that is not the application's or an application outside the caller's organization. The directory then holds the application's services beside the platform's own, with their liveness; a service the project declares no definition for serves on its zone alone. A definition naming other functions than the class serves fails the registration, and with it the connection, until kinotic sync runs again.

Parameter and return types

Arguments and results travel as JSON, so a function's types are published as JSON carries them:

  • A Promise or PromiseLike result is asynchronous and an Observable result is a stream, of the type it resolves to or emits.
  • any, unknown, and a type parameter with no constraint accept anything; a constrained type parameter is its constraint.
  • Interfaces, classes (their fields, without their methods), generic instantiations such as Page<Report>, mapped types such as Partial<Report> and Pick<Report, 'id'>, intersections, and unions of objects are objects, named for the type and its type arguments (Page_Report, Partial_Report).
  • Record<string, V> and {[key: string]: V} are maps, enums are enums, and a union of string literals is a string.
  • A Date is published as a date, but it arrives as the ISO-8601 string a date travels as: the service receives a string, and a TypeScript caller gets a string back. kinotic generate logs a warning for each one, and the directory carries the same warning in the warnings metadata of the parameter, or of the function for a result.
  • A Map, Set, Uint8Array, Buffer, bigint, tuple, or function fails generation with the reason and the type to declare instead.

Decorators

@Publish(namespace?, name?, advertise?)

Marks a class for publication. The full service identifier becomes namespace.ClassName; both parts are optional (the name defaults to the class name). A service published with no namespace is addressed by its name in its zone, srv://<zone>~ClassName, and the platform's service directory names it in its zone's namespace, <zone>.ClassName, so services of the same name in different applications stay apart. Passing advertise as true additionally advertises the service in the platform service directory, so it appears in directory listings for browsing and invocation — publishing alone makes a service callable over RPC without listing it.

@Zone(zone)

Declares the zone the service is addressable in, relative to the client's zonePrefix. The zone is one or more dot-separated labels of lowercase letters, digits, and interior dashes.

@Version(version)

Sets the semantic version for the service in X.Y.Z format. Callers can pin to a specific version when resolving the service.

@Scope

A getter or method decorator that marks the member providing the scope identifier. Scope targets requests at one specific instance of a service — for example the copy running on a particular node or device.

import { Publish, Scope } from '@kinotic-ai/core'

@Publish('com.example')
class DeviceService {
    deviceId: string

    @Scope
    get scope(): string {
        return this.deviceId
    }

    constructor(deviceId: string) {
        this.deviceId = deviceId
    }

    getStatus(): string {
        return `Status for device ${this.deviceId}`
    }
}

When a caller targets a specific scope (e.g., device-42), the platform routes the request to the instance whose deviceId matches.

@Context

A method decorator that marks a method as receiving the request context: a ServiceContext carrying the calling participant — the same caller a Java service receives as its Participant parameter — plus whatever a registered ContextInterceptor adds.

The context parameter must be the method's final parameter. Callers never pass it — the platform appends it after the caller-supplied arguments.

import { Publish, Context, type ServiceContext } from '@kinotic-ai/core'
import { isOrganizationParticipant, requireParticipant } from '@kinotic-ai/management-api'

@Publish('com.example')
class OrderService {
    @Context
    async listOrders(status: string, context: ServiceContext): Promise<Order[]> {
        const caller = requireParticipant(context.participant, isOrganizationParticipant)
        return this.orders.findByOrganization(caller.organizationId, status)
    }
}

context.participant is the participant the gateway authenticated on the calling connection, serialized with its scope discriminator and ids (type, organizationId, applicationId). It is absent only for an invocation that originated inside the platform with no participant bound, such as a scheduled job.

requireParticipant is the counterpart of a Java service's securityContext.requireParticipant(OrganizationParticipant.class): it returns the participant narrowed by the guard you pass (isSystemParticipant, isOrganizationParticipant, or isApplicationParticipant), throws if the invocation carries no participant, and fails the invocation with an AuthorizationException — the same name a Java refusal carries — when the caller does not hold that scope. The guards alone narrow without throwing, for a method that serves more than one scope.

A ContextInterceptor registered through Kinotic.serviceRegistry.registerContextInterceptor(...) receives the context with the participant already set and returns the context the method sees, so it can add data derived from the caller or from the request headers:

Kinotic.serviceRegistry.registerContextInterceptor({
    async intercept(event, context) {
        return { ...context, tenant: await tenants.forParticipant(context.participant) }
    }
})
Copyright © 2026