Quick start¶
The repository's example/
is one small service, a tenant-scoped notes API, generated twice from one
proto set: a Go server and a TypeScript client. One command builds tacplus,
generates both, starts the server and drives it with the client.
Run it¶
You need Go, Node and protoc, plus the protoc plugins for Go and
TypeScript (see what tacplus build needs),
and just.
just example runs example/run.sh. The output lands in example/target/
(git-ignored), and the run ends with:
MCP tools: create_note, get_note, list_notes
created note-1 "hello from typescript"
fetched note-1 "hello from typescript"
listed note-1, note-2
rejected empty text: field text below min size of 1 bytes, got: 0
The first line is the server listing the MCP tools generated from its rpcs. The last line is the generated TypeScript client refusing an invalid request before it reaches the wire; the server would refuse it too, with the same message, because both sides are generated from the same annotation.
1. What you write: the proto¶
Everything the generator needs to know about the service is an annotation in
example/proto/notes/notes.proto:
// Copyright (c) 2026 Seabeam LLC
// SPDX-License-Identifier: Apache-2.0
//
// The example service: notes, scoped to a tenant namespace.
//
// What you write is this file. What you get is in example/README.md.
syntax = "proto3";
package notesapi;
import "sys/spec.proto";
// The service identity used across the mesh.
option (spec.service) = {name: "notes"};
message Note {
string id = 1 [(spec.assert_required) = true, (spec.indexed) = true];
string tenant_namespace = 2 [(spec.assert_required) = true];
string text = 3 [(spec.assert_required) = true, (spec.assert_max_size) = 1024];
}
message CreateNoteRequest {
string tenant_namespace = 1 [(spec.assert_required) = true, (spec.assert_min_size) = 1];
string text = 2 [(spec.assert_required) = true, (spec.assert_min_size) = 1, (spec.assert_max_size) = 1024];
}
message CreateNoteResponse {
Note note = 1;
}
message GetNoteRequest {
string tenant_namespace = 1 [(spec.assert_required) = true, (spec.assert_min_size) = 1];
string id = 2 [(spec.assert_required) = true];
}
message GetNoteResponse {
Note note = 1;
}
// A PAGE collection request: the filter fields plus page_size and page_token.
message ListNotesRequest {
string tenant_namespace = 1 [(spec.assert_required) = true, (spec.assert_min_size) = 1];
int32 page_size = 2;
string page_token = 3;
}
// A PAGE collection response: one repeated items field plus next_page_token.
message ListNotesResponse {
repeated Note items = 1 [(spec.assert_max_size) = 1000];
string next_page_token = 2;
int64 total_count = 3;
}
// A client-to-service API: called by authenticated users through the API
// gateway. Every user rpc on it is also published as an MCP tool.
service NotesApi {
option (spec.service_c2s) = {auth_entitled_users: true};
// Create a note in the caller's tenant namespace and return it with its new id.
rpc CreateNote(CreateNoteRequest) returns (CreateNoteResponse) {
option (spec.rpc) = {principal: RPC_PRINCIPAL_USER, exposure: RPC_EXPOSURE_EXTERNAL, flags: [RPC_FLAG_AUTH_ENTITLED_USERS], target_namespace_field: "tenant_namespace"};
}
// Fetch one note by id. NOT_FOUND if the id is unknown in this tenant namespace.
rpc GetNote(GetNoteRequest) returns (GetNoteResponse) {
option (spec.rpc) = {principal: RPC_PRINCIPAL_USER, exposure: RPC_EXPOSURE_EXTERNAL, flags: [RPC_FLAG_AUTH_ENTITLED_USERS], target_namespace_field: "tenant_namespace"};
}
// List the notes in the tenant namespace, ordered by id, one page at a time.
rpc ListNotes(ListNotesRequest) returns (ListNotesResponse) {
option (spec.rpc) = {principal: RPC_PRINCIPAL_USER, exposure: RPC_EXPOSURE_EXTERNAL, flags: [RPC_FLAG_AUTH_ENTITLED_USERS], target_namespace_field: "tenant_namespace"};
option (spec.method_collection) = {
mode: COLLECTION_MODE_PAGE
order_by: "id"
default_page_size: 50
};
}
}
// A state root: a named partition of distributed state. tacplus generates the
// state service (Get/Set/Watch/Delete), typed accessors and watch streams for
// it; the state store that backs it is a platform service, so this example
// declares it and shows what is generated rather than running it.
message NotesState {
option (spec.service_state) = {name: "NotesState", label: ["notesapi"], export: true};
repeated Note notes = 1 [(spec.indexed) = true];
}
Reading it top down:
option (spec.service)names the service identity used across the mesh.assert_required,assert_min_sizeandassert_max_sizeare field constraints. They become validation in the server wrapper and in the client, in every language.service_c2smakesNotesApia client-to-service API: called by authenticated users through the gateway. Every user rpc on it is also an MCP tool, without any further annotation.spec.rpcsets each rpc's principal, exposure and auth flags, andtarget_namespace_fieldnames the field that scopes it to a tenant.method_collectionwithCOLLECTION_MODE_PAGEmakesListNotesa paged collection: the wrapper decodes the page token for the handler, and the clients walk the pages.service_statemakesNotesStatea state root, a partition of distributed state this service owns.
Every option is in the annotation reference.
The client gets its own identity in a second, three-line package that declares what it calls:
// Copyright (c) 2026 Seabeam LLC
// SPDX-License-Identifier: Apache-2.0
//
// The client's own identity. Full-client targets (TypeScript here) emit C2S
// clients for the dependencies the HOSTED package declares, so the TypeScript
// build hosts this package and gets a client of NotesApi.
syntax = "proto3";
package notesclient;
import "sys/spec.proto";
option (spec.service) = {name: "notesclient"};
option (spec.grpc_c2s_client) = {service: "notesapi.NotesApi"};
The proto directory also holds registry.yaml (stable service ids, managed
by tacplus), dependencies.yaml (the pinned language dependencies the
generated code builds with; every proto source needs one) and, under
platform/, the token-mint and health contracts every hosted service is
generated against.
2. What you write: tacplus.yaml¶
run.sh writes one config per build. Only the language and package differ:
The last segment of pkg names the proto package the build hosts: the Go
build hosts notesapi and gets its server; the TypeScript build hosts
notesclient and gets a client of everything notesclient declares. Then
each build is one command:
3. What you get¶
For Go, in example/target/go/model:
| Path | What it is |
|---|---|
s2sm/grpc/servers/notes/notesapi/notes_api.go |
The NotesApiHandler interface the server implements, and the NotesApi wrapper that validates every request and response, runs the auth seams, builds the Caller, and only then calls the handler. |
.../notesapi/notes_api_mcp.go |
The three rpcs as MCP tools (create_note, get_note, list_notes), with JSON schemas derived from the messages and descriptions from the rpc comments. |
.../notesapi/notes_state_service.go, s2sm/state/notes/notes_state/ |
The state service (Get/Set/Watch/Delete) and typed accessors for NotesState. |
s2sm/s2sm.go |
The ServiceManager that wires identity, TLS, health and state in a deployed service. |
types/notes/notesapi/ |
The protobuf types with typed validation helpers. |
registry.json |
The whole model as data, including the tool catalogue. |
chassis/ |
The runtime libraries the generated code calls into. |
For TypeScript, in example/target/ts/model: c2sm/, the client
ServiceManager, whose notes_api_client() validates each request, attaches
the bearer token from your token provider, and turns the paged listNotes
into an async iterable; and types/, the message types with the same
validation.
4. The handler¶
The Go server is three methods on an in-memory map. That is all the Go a
service author writes; the rest of main.go hosts the generated wrapper on
a plain gRPC server and stands in for the platform's token verification with
one demo token.
example/server/main.go
// Copyright (c) 2026 Seabeam LLC
// SPDX-License-Identifier: Apache-2.0
// The example server. It hosts the generated NotesApi wrapper on a plain gRPC
// server: the wrapper validates every request, runs the auth seams, and only
// then calls the handler below. In a deployed service the generated
// ServiceManager (s2sm.New + Run) does all of this wiring and also brings up
// identity, TLS and state; it needs the platform's identity service, so this
// standalone example wires the wrapper directly and verifies one demo token.
//
// Startup contract: prints the gRPC address on the first line of stdout, then
// serves until SIGINT/SIGTERM.
package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"os"
"os/signal"
"sort"
"strings"
"sync"
"syscall"
"example.com/notesapi/model/chassis/collection"
"example.com/notesapi/model/chassis/servicemanager"
wrapperToken "example.com/notesapi/model/chassis/token"
server "example.com/notesapi/model/s2sm/grpc/servers/notes/notesapi"
types "example.com/notesapi/model/types/notes/notesapi"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// demoBearer is the only credential this example accepts. A real service
// verifies a signed token against the platform's keys; see chassis/grpcserver.
const demoBearer = "Bearer demo-token"
const (
demoIssuer = "https://issuer.example.com"
demoSubject = "spiffe://example.com/tenants/alpha/users/demo"
demoAudience = "notes"
)
// demoToken is what the auth seams return on success: the wrapper builds the
// Caller it hands the handler from it.
var demoToken = mustDemoToken()
func mustDemoToken() *wrapperToken.TokenWrapper {
part := func(v any) string {
raw, err := json.Marshal(v)
if err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(raw)
}
jwt := part(map[string]string{"alg": "EdDSA", "kid": "demo"}) + "." +
part(map[string]string{"iss": demoIssuer, "sub": demoSubject, "aud": demoAudience}) + "." +
base64.RawURLEncoding.EncodeToString([]byte("demo-signature"))
tok, err := wrapperToken.ParseToken(jwt)
if err != nil {
panic(err)
}
return tok
}
func requireBearer(ctx context.Context) error {
md, _ := metadata.FromIncomingContext(ctx)
if vals := md.Get("authorization"); len(vals) == 1 && vals[0] == demoBearer {
return nil
}
return status.Error(codes.Unauthenticated, "missing or unknown bearer token")
}
// notes is the handler: the only code a service author writes.
type notes struct {
mu sync.Mutex
next int
notes map[string]*types.Note // id -> note
}
func (n *notes) CreateNote(_ context.Context, caller *servicemanager.Caller, req *types.CreateNoteRequest) (*types.CreateNoteResponse, error) {
n.mu.Lock()
defer n.mu.Unlock()
n.next++
note := &types.Note{
Id: fmt.Sprintf("note-%d", n.next),
TenantNamespace: req.GetTenantNamespace(),
Text: req.GetText(),
}
n.notes[note.Id] = note
fmt.Fprintf(os.Stderr, "CreateNote by %s: %s\n", caller.Token().GetSubject(), note.Id)
return &types.CreateNoteResponse{Note: note}, nil
}
func (n *notes) GetNote(_ context.Context, _ *servicemanager.Caller, req *types.GetNoteRequest) (*types.GetNoteResponse, error) {
n.mu.Lock()
defer n.mu.Unlock()
note, ok := n.notes[req.GetId()]
if !ok || note.GetTenantNamespace() != req.GetTenantNamespace() {
return nil, status.Errorf(codes.NotFound, "note %q not found", req.GetId())
}
return &types.GetNoteResponse{Note: note}, nil
}
// ListNotes is a PAGE collection handler: the wrapper decodes the page token
// into PageParams and encodes the cursor this returns into next_page_token.
func (n *notes) ListNotes(_ context.Context, _ *servicemanager.Caller, req *types.ListNotesRequest, page collection.PageParams) (collection.Page[*types.Note], error) {
n.mu.Lock()
defer n.mu.Unlock()
var all []*types.Note
for _, note := range n.notes {
if note.GetTenantNamespace() == req.GetTenantNamespace() {
all = append(all, note)
}
}
sort.Slice(all, func(i, j int) bool { return all[i].GetId() < all[j].GetId() })
start := 0
if page.After != nil && len(page.After.Keys) == 1 {
for start < len(all) && all[start].GetId() <= page.After.Keys[0] {
start++
}
}
end := min(start+page.Limit, len(all))
out := collection.Page[*types.Note]{Items: all[start:end], Total: int64(len(all))}
if end < len(all) {
out.NextAfter = &collection.Cursor{Keys: []string{all[end-1].GetId()}}
}
return out, nil
}
func main() {
handler := ¬es{notes: map[string]*types.Note{}}
wrapper := server.NewNotesApi(handler)
// The auth seams. Each returns the token it verified; the wrapper turns it
// into the Caller the handler receives.
wrapper.SetAuthorizeFunc(func(ctx context.Context, _ string, _ any) (*wrapperToken.TokenWrapper, error) {
if err := requireBearer(ctx); err != nil {
return nil, err
}
return demoToken, nil
})
wrapper.SetAuthenticateFunc(func(ctx context.Context) (*wrapperToken.TokenWrapper, error) {
if err := requireBearer(ctx); err != nil {
return nil, err
}
return demoToken, nil
})
wrapper.SetAuthenticateAtOrBelowFunc(func(ctx context.Context, _ string) (*wrapperToken.TokenWrapper, error) {
if err := requireBearer(ctx); err != nil {
return nil, err
}
return demoToken, nil
})
grpcSrv := grpc.NewServer()
if err := wrapper.RegisterServices(grpcSrv); err != nil {
fmt.Fprintln(os.Stderr, "register:", err)
os.Exit(1)
}
// The same rpcs, as the generated MCP tool catalogue.
var tools []string
for _, t := range server.NotesApiMcpTools() {
tools = append(tools, t.Name)
}
fmt.Fprintf(os.Stderr, "MCP tools: %s\n", strings.Join(tools, ", "))
addr := os.Getenv("NOTES_ADDR")
if addr == "" {
addr = "127.0.0.1:0"
}
lis, err := net.Listen("tcp", addr)
if err != nil {
fmt.Fprintln(os.Stderr, "listen:", err)
os.Exit(1)
}
fmt.Println(lis.Addr().String())
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
grpcSrv.GracefulStop()
}()
if err := grpcSrv.Serve(lis); err != nil {
fmt.Fprintln(os.Stderr, "serve:", err)
os.Exit(1)
}
}
5. The client¶
example/client/client.mjs
// Copyright (c) 2026 Seabeam LLC
// SPDX-License-Identifier: Apache-2.0
//
// The example client: the generated TypeScript C2S ServiceManager talking to
// the Go server. The address arrives in NOTES_ADDR.
import * as grpc from '@grpc/grpc-js';
import { create } from '@bufbuild/protobuf';
import { ServiceManager } from '../model/dist/c2sm/index.js';
import {
CreateNoteRequestSchema,
GetNoteRequestSchema,
ListNotesRequestSchema,
} from '../model/dist/types/services/notes/services/index.js';
const addr = process.env.NOTES_ADDR;
if (!addr) {
throw new Error('NOTES_ADDR is not set');
}
// The third argument is the token provider: the generated client attaches the
// bearer token it returns to every authenticated call.
const sm = new ServiceManager(addr, grpc.credentials.createInsecure(), async () => 'demo-token');
const api = sm.grpc().notesapi().notes_api_client();
const tenantNamespace = 'tenant.alpha';
const created = await api.createNote(create(CreateNoteRequestSchema, { tenantNamespace, text: 'hello from typescript' }));
console.log('created', created.note.id, JSON.stringify(created.note.text));
const fetched = await api.getNote(create(GetNoteRequestSchema, { tenantNamespace, id: created.note.id }));
console.log('fetched', fetched.note.id, JSON.stringify(fetched.note.text));
await api.createNote(create(CreateNoteRequestSchema, { tenantNamespace, text: 'second note' }));
// A PAGE collection: the generated client walks the pages for you.
const ids = [];
for await (const note of await api.listNotes(create(ListNotesRequestSchema, { tenantNamespace, pageSize: 1 }))) {
ids.push(note.id);
}
console.log('listed', ids.join(', '));
// Validation runs in the generated client before anything goes on the wire.
try {
await api.createNote(create(CreateNoteRequestSchema, { tenantNamespace, text: '' }));
throw new Error('empty text was accepted');
} catch (err) {
console.log('rejected empty text:', err.message.split('\n')[0]);
}
sm.close?.();
What the example leaves out¶
- A deployed service calls
s2sm.New()andsm.Run(handler)instead of hosting the wrapper by hand. That brings up identity, TLS, health and state, and needs the platform's token mint and state store running. The example declares those contracts so generation has them, but does not run them, so the state service is generated and not exercised. run.shletsgo mod tidypick the generated Go module's versions, which a real service would pin.
Next¶
- Languages: what each target gets.
- User guide:
tacplus.yaml, the annotations by topic, and the implementer rules. - Generated API: the contracts and guarantees of the generated surfaces.