Get to know Cadenya
We’re developers who love to build. We set out to create a yes-code platform that makes building agents feel like the best parts of building software.
Designing Cadenya's API
Nerding Out
$ whoami
> Robert Ross
$ date
> Sun Sep 6 09:11:26 EDT 2026
$ pwd
> Passenger Coffee, 131 N Plum St, Lancaster, PA 17602
Cadenya is an API-first agent runtime. Getting there, though, has been a year in the making. REST APIs are a must-have for any platform now, and I wanted to design one that was consistent enough to support automated SDK generation and API reference documentation.
Protobuf and gRPC
Cadenya uses Protobuf as its IDL (interface definition language). By using Protobuf and gRPC to define services, it became easy to go from a generated server to an OpenAPI specification and, from there, to generated SDKs and API reference documentation.
I settled on this toolchain:
- Buf CLI: Generates Protobuf message types and services.
- Gnostic: Generates an OpenAPI specification from gRPC definitions.
- Redwood: An in-house, open-source SDK generator. I originally used Stainless.com, but it was acquired and shut down.
- Envoy gRPC-JSON transcoder: Exposes the API as a JSON REST API.
- I considered ConnectRPC, but it felt like another tool, and I’m using Envoy already for other parts of the stack.
- Mintlify: Hosts the generated API reference in our API docs.
Oh, and Golang. Go rules.
These tools combine to make it simple to generate an endpoint and its OpenAPI specification, then update the SDKs. Below is a taste of where this post is going.
service ToolService {
/* Tool Sets */
rpc CreateToolSet(CreateToolSetRequest) returns (ToolSet) {
option (cadenya.api.v1.required_scope) = "tools:manage";
option (google.api.http) = {
post: "/v1/workspaces/{workspace_id}/tool_sets"
body: "*"
};
option (gnostic.openapi.v3.operation) = {
summary: "Create a new tool set"
description: "Creates a new tool set in the workspace"
tags: "Tool Sets"
};
}
}
The Contract: Protobufs and gRPC
Consistency is the most important thing for an API. Every API will end up with some “quirks” in it (Cadenya is no different), but I wanted to focus on envelope shapes that were always consistent. And there’s one API that is top-notch at this: Kubernetes.
Cadenya therefore has a few envelope shapes that are repeating characters in our show:
AccountResource: An envelope for a resource tied to a customer’s top-levelAccountrecord.WorkspaceResource: A specialization ofAccountResourcethat always adds aworkspace_idfield and acreated_by_idfor the actor.WorkspaceOperation: A type that represents an operation within a workspace, such as an objective, data sync, or event.
These envelopes are paired with a Spec message, à la Kubernetes. So an envelope will render with a structure similar to this:
{
"metadata": {...}, // name, workspaceId, labels, etc.
"spec": {...}, // resource specification, different per resource type
"info": {...}, // calculated fields (counts, for example)
}
The most important one is the Workspace Resource, which is identified by the ResourceMetadata protocol buffer.
message ResourceMetadata {
// Unique identifier for the resource (prefixed ULID, e.g., "agent_01HXK...")
string id = 1 [(gnostic.openapi.v3.property) = {read_only: true}];
// Account this resource belongs to for multi-tenant isolation (prefixed ULID)
string account_id = 2 [(gnostic.openapi.v3.property) = {
example: {yaml: "account_01HXKD2E5NQM3T9AYWCFTJHJVF"}
read_only: true
}];
// Workspace this resource belongs to for organizational grouping (prefixed ULID)
string workspace_id = 3 [(gnostic.openapi.v3.property) = {
example: {yaml: "workspace_01HXKD2E5NQM3T9AYWCF133E3Q"}
read_only: true
}];
// Human-readable name for the resource (e.g., "Customer Support Agent", "Email Tool")
// Required for resources that users interact with directly
string name = 4 [
(buf.validate.field).string = {
min_len: 0
max_len: 64
},
(buf.validate.field).required = true
];
// External ID for the resource (e.g., a workflow ID from an external system)
string external_id = 5 [(buf.validate.field).string = {
min_len: 0
max_len: 64
}];
// Key-value pairs for categorization and filtering. Values are 0-63
// alphanumeric characters with "-", "_", or "." allowed between; keys
// follow the same shape and additionally accept an optional DNS-subdomain
// prefix (e.g. "cadenya.com/") of at most 253 characters.
// Examples: {"environment": "production", "team": "platform", "version": "v2"}
map<string, string> labels = 6 [
(buf.validate.field).map = {
max_pairs: 10
keys: {
string: {
min_len: 1
// Longest legal key: a 253-char DNS-subdomain prefix + "/" +
// a 63-char name.
max_len: 317
pattern: "^([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?)*/)?([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9]$"
}
}
values: {
string: {
max_len: 63
pattern: "^(([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9])?$"
}
}
},
(buf.validate.field).cel = {
id: "labels.key_prefix_len"
message: "label key prefixes must be at most 253 characters"
expression: "this.all(k, !k.contains('/') || k.indexOf('/') <= 253)"
}
];
// ID of the actor (user or service account) that created this resource
string profile_id = 7 [(gnostic.openapi.v3.property) = {
example: {yaml: "profile_01HXKD2E5NQM3T9AYWCFS0AP08"}
read_only: true
}];
// Timestamp when this resource was created
google.protobuf.Timestamp created_at = 8 [(gnostic.openapi.v3.property) = {read_only: true}];
// Timestamp when this resource was last updated
optional google.protobuf.Timestamp updated_at = 10 [(gnostic.openapi.v3.property) = {read_only: true}];
}
This is Cadenya’s most commonly used metadata message because most resources are workspace-scoped.
For example, the ToolSet message is very simple. Most of the complex message definitions are in the Spec and Info types:
message ToolSet {
option (gnostic.openapi.v3.schema) = {
required: [
"metadata",
"spec",
"state"
]
};
enum State {
STATE_UNSPECIFIED = 0;
STATE_ACTIVE = 1;
STATE_ARCHIVED = 2;
}
ResourceMetadata metadata = 1;
ToolSetSpec spec = 2;
ToolSetInfo info = 3 [(gnostic.openapi.v3.property) = {read_only: true}];
State state = 4 [(gnostic.openapi.v3.property) = {read_only: true}];
}
A few notes:
- A resource’s state is always top-level. This complies with Google’s AIP specification for states.
- The
infoobject always has a message type unique to the resource. For example,ToolSetInfogenerates a proper type in SDKs. - The
metadatakey is represented by a genericResourceMetadatamessage.
I can hear it now:
But why separate this message type? That adds another object to every JSON request and response.
Yes, it does. Clients do need to dig one object inward to retrieve a metadata.id field. But coding assistants powered by LLMs don’t mind this design. They tend to thrive on it because of the overall consistency of the message design.
Protobufs and Golang Interfaces
There’s another subtlety to choosing this “leaf” pattern: Golang interfaces.
Protobuf messages generate getter methods in the form GetFIELDNAME on structs. For our ResourceMetadata above, that means the message will have this generated GetName() method:
func (x *ResourceMetadata) GetName() string {
if x != nil {
return x.xxx_hidden_Name
}
return ""
}
Because every resource has a separate metadata field in its envelope, type checking becomes dead simple. We can check whether any message is a workspace resource with a simple type assertion:
package main
import (
"fmt"
apiv1 "go.cadenya.com/cadenya/internal/proto/cadenya/api/v1"
"google.golang.org/protobuf/proto"
)
type WorkspaceResource interface {
GetMetadata() *apiv1.ResourceMetadata
SetMetadata(*apiv1.ResourceMetadata)
}
func main() {
ts := &apiv1.ToolSet{}
profile := &apiv1.Profile{}
fmt.Printf("is a ToolSet a workspace resource? %v\n", isWorkspaceResource(ts))
// true
fmt.Printf("is a Profile a workspace resource? %v\n", isWorkspaceResource(profile))
// false
}
func isWorkspaceResource(m proto.Message) bool {
_, ok := m.(WorkspaceResource)
return ok
}
This technique is used in gRPC interceptors, loggers, and repository wrappers to route business logic seamlessly through middleware.
Creating Resources
The metadata Protobuf design choice applies when creating resources, too. However, an API client won’t provide created_by_id or created_at values, so there’s a separate CreateResourceMetadata message that is a slimmed-down version with Protovalidate annotations included.
message CreateResourceMetadata {
option (gnostic.openapi.v3.schema) = {
required: ["name"]
};
// Human-readable name for the resource (e.g., "Customer Support Agent", "Email Tool")
string name = 1 [
(buf.validate.field).string = {
min_len: 0
max_len: 64
},
(buf.validate.field).required = true
];
// External ID for the resource (e.g., a workflow ID from an external system)
string external_id = 2 [(buf.validate.field).string = {
min_len: 0
max_len: 64
}];
// Key-value pairs for categorization and filtering. Values are 0-63
// alphanumeric characters with "-", "_", or "." allowed between; keys
// follow the same shape and additionally accept an optional DNS-subdomain
// prefix (e.g. "cadenya.com/") of at most 253 characters.
// Examples: {"environment": "production", "team": "platform", "version": "v2"}
map<string, string> labels = 3 [
(buf.validate.field).map = {
max_pairs: 10
keys: {
string: {
min_len: 1
// Longest legal key: a 253-char DNS-subdomain prefix + "/" +
// a 63-char name.
max_len: 317
pattern: "^([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?)*/)?([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9]$"
}
}
values: {
string: {
max_len: 63
pattern: "^(([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9])?$"
}
}
},
(buf.validate.field).cel = {
id: "labels.key_prefix_len"
message: "label key prefixes must be at most 253 characters"
expression: "this.all(k, !k.contains('/') || k.indexOf('/') <= 253)"
}
];
}
Our CreateToolSetRequest is simple, too:
message CreateToolSetRequest {
option (gnostic.openapi.v3.schema) = {
required: [
"metadata",
"spec"
]
};
string workspace_id = 1 [(gnostic.openapi.v3.property) = {
example: {yaml: "workspace_01HXKD2E5NQM3T9AYWCF133E3Q"}
read_only: true
}];
CreateResourceMetadata metadata = 2 [(buf.validate.field).required = true];
ToolSetSpec spec = 3 [(buf.validate.field).required = true];
}
Because the specification message (ToolSetSpec) is the same, our internal Go code (repositories, ORM, etc.) can reuse the message across its type definitions. That’s another win for separating the spec type.
Tenanted URLs
Cadenya has accounts and workspaces tables. An account can have several workspaces, and most resources have a foreign-key association with a workspace. Originally, the API design was fairly flat, and I tied workspace resources to the bearer token provided in the Authorization header. But after months of fighting the design, it was clear that the URL paths themselves needed to include the workspace_id.
You’ll see this URL pattern for 95% of the API endpoints:
/v1/workspaces/{workspace_id}/agents
/v1/workspaces/{workspace_id}/tool_sets
/v1/workspaces/{workspace_id}/workspace_secrets
That means the Protobuf message for the request looks like this:
// List agents request
message ListAgentsRequest {
// Workspace ID.
string workspace_id = 1 [(gnostic.openapi.v3.property) = {
example: {yaml: "workspace_01HXKD2E5NQM3T9AYWCF133E3Q"}
read_only: true
}];
// ...other fields omitted...
}
You may have even caught the workspace_id in the CreateToolSetRequest above, too.
This pattern adds one additional Protobuf field to messages, but the complexity tax is worth it once you see what it enables in the backend implementation.
More Go Interface Fun
There are (as of writing) 133 instances of string workspace_id = in the protocol buffer files powering the API, which means we have a GetWorkspaceId() string Go method defined 133 times, too.
So, the gRPC service can then, you guessed it, check whether the message implements the GetWorkspaceId() method in the middleware layer using gRPC interceptors:
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
// Is this a `/v1/workspaces/{workspace_id}` request?
scoped, ok := req.(WorkspaceScoped)
if !ok {
return handler(ctx, req)
}
// Find the workspace actor for this request, if one exists.
ac, err := ResolveWorkspaceActor(ctx, db, scoped.GetWorkspaceId())
if err != nil {
return nil, err
}
// Stamp the canonical workspace ID (the actor's) onto the context so
// downstream queries and repositories can retrieve it.
ctx = servercontext.WithActor(ctx, ac)
ctx = servercontext.WithWorkspaceID(ctx, ac.WorkspaceID)
return handler(ctx, req)
}
This gives us an initial gut check on whether the actor has access to the workspace being accessed, no matter the service endpoint, and stamps the data onto the context.Context threaded through the request.
Note: The service endpoint is the one that actually performs the permission check.
List endpoints and pagination
Every API needs to list data. A consistent design for list endpoints is also essential for SDK generation. I made a few choices for Cadenya’s API:
- Cursor-based pagination.
itemsas the array container in responses.paginationas the pagination object in responses.
message ListWorkspaceSecretsRequest {
// The workspace whose secrets will be listed.
string workspace_id = 1 [(gnostic.openapi.v3.property) = {
example: {yaml: "workspace_01HXKD2E5NQM3T9AYWCF133E3Q"}
read_only: true
}];
// Maximum number of results to return
int32 limit = 2 [(buf.validate.field).int32 = {
gte: 0
lte: 100
}];
// Pagination cursor from previous response
string cursor = 3;
// Sort order for results (asc or desc by creation time)
string sort_order = 20 [(buf.validate.field).string = {
in: [
"asc",
"desc",
""
]
}];
// When set to true, you may use more of your allotted API rate limit
bool include_info = 30;
}
And the response:
message ListWorkspaceSecretsResponse {
option (gnostic.openapi.v3.schema) = {
required: ["items"]
};
repeated WorkspaceSecret items = 1;
Page pagination = 2;
}
And the Page type, for completeness:
// Page carries cursor-based pagination state. There is no total: the cursor
// walks the result set without ever counting it, and a count would cost a second
// query on every list.
message Page {
option (gnostic.openapi.v3.schema) = {
required: ["nextCursor"]
};
string next_cursor = 1;
}
Why cursors?
Cursors let us change how we paginate without breaking clients. The cursor in a list response is an opaque token that carries whatever state we need to resume the query, and clients simply hand it back.
The primary id field on every resource and operation in Cadenya is also a ULID. This makes it more efficient to sort records coming out of PostgreSQL because ULIDs are lexicographically sortable.
Gnostic Annotations
You’ve likely noticed the (gnostic.openapi.v3.schema) option on our Protobuf messages. These are Gnostic annotations that define how the API should be rendered in our OpenAPI specification, which, in turn, determines how our SDKs behave.
Several Gnostic annotations customize the OpenAPI specification generation. The entire specification lives on GitHub. You can see how Cadenya configures its own Gnostic annotations in our Buf module.
The API, The Go, and the Database
You don’t have an API without a database. These messages have to be stored somewhere, right? Several of the design choices outlined above make that easier, particularly because Cadenya uses Go for its backend.
Cadenya’s backend also uses Ent as its database ORM. The database follows a simple rule: every table must be modeled after one of Cadenya’s three envelope types: workspace resources, account resources, and operations. Tool sets, for example, have all the metadata fields from our Protobuf message defined as columns.
Table "public.tool_sets"
Column | Type | Collation | Nullable | Default
---------------+--------------------------+-----------+----------+---------------------------
--> id | character varying | | not null |
--> account_id | character varying | | not null |
--> workspace_id | character varying | | not null |
--> created_by_id | character varying | | not null |
--> external_id | character varying | | |
--> name | character varying | | not null |
spec | bytea | | not null |
--> labels | jsonb | | |
discarded_at | timestamp with time zone | | |
--> created_at | timestamp with time zone | | not null | now()
updated_at | timestamp with time zone | | not null | now()
next_sync_at | timestamp with time zone | | |
status | tool_set_status | | not null | 'active'::tool_set_status
just_in_time | boolean | | not null | false
From there, the Ent schema (ent/schema/toolset.go) uses an Ent mixin called WorkspaceResource to add all the fields from our ResourceMetadata Protobuf to the database schema.
type WorkspaceResourceMixin struct {
mixin.Schema
}
func (WorkspaceResourceMixin) Edges() []ent.Edge {
return []ent.Edge{
edge.To("workspace", Workspace.Type).Required().Unique().Field("workspace_id"),
edge.To("account", Account.Type).Required().Unique().Field("account_id"),
}
}
func (WorkspaceResourceMixin) Fields() []ent.Field {
return []ent.Field{
field.String("account_id").NotEmpty(),
field.String("workspace_id").NotEmpty(),
field.String("external_id").Optional().Nillable(),
field.Other("labels", &scalarvalues.Labels{}).SchemaType(map[string]string{
dialect.Postgres: "jsonb",
}).Optional(),
field.String("name").NotEmpty(),
}
}
Ent is more of a code generator than a package you use directly for queries. When you generate code with Ent, you get a lot of boilerplate added to your repository. After spelunking through the generated code, I noticed that a Mutation type is created for database inserts and updates. For example, setting a name looks like this:
// toolset_create.go
// ToolSetCreate is the builder for creating a ToolSet entity.
type ToolSetCreate struct {
config
mutation *ToolSetMutation
hooks []Hook
conflict []sql.ConflictOption
}
// SetName sets the "name" field.
func (_c *ToolSetCreate) SetName(v string) *ToolSetCreate {
_c.mutation.SetName(v)
return _c
}
Because our Protobuf messages and database tables are named consistently, I decided to abstract mutation updates into a helper function:
// MetadataFields is the common interface for all metadata types (read, create, update).
type MetadataFields interface {
GetName() string
GetExternalId() string
GetLabels() map[string]string
}
// AccountResourceDatabaseRecord describes account-level records (profiles, integrations, etc.).
type AccountResourceDatabaseRecord interface {
GetID() string
GetAccountID() string
GetName() string
GetExternalID() *string
GetLabels() *scalarvalues.Labels
GetCreatedAt() time.Time
}
// WorkspaceResourceDatabaseRecord describes workspace-level records (tool sets, agents, etc.).
// It is always a superset of an account resource record.
type WorkspaceResourceDatabaseRecord interface {
AccountResourceDatabaseRecord
GetWorkspaceID() string
}
// AssignMetadataToMutation assigns user-provided metadata fields to a workspace-level Ent mutation.
func AssignMetadataToMutation(md MetadataFields, parent WorkspaceResourceDatabaseRecord, mutation MetadataMutation) {
mutation.SetAccountID(parent.GetAccountID())
mutation.SetWorkspaceID(parent.GetWorkspaceID())
mutation.SetLabels(scalarvalues.NewLabels(md.GetLabels()))
mutation.SetName(md.GetName())
if len(md.GetExternalId()) > 0 {
mutation.SetExternalID(md.GetExternalId())
} else {
mutation.ClearExternalID()
}
if t, ok := parent.(*ent.Actor); ok {
if ma, mok := mutation.(interface{ SetCreatedByID(string) }); mok {
ma.SetCreatedByID(t.GetProfileID())
}
}
}
The creation of a simple resource then becomes two lines:
// Get the create operation from Ent, which contains the mutation.
create := r.db.GetToolSetClient().Create()
records.AssignMetadataToMutation(req.GetMetadata(), actor, create.Mutation())
Ragebait: Protobufs in the database
In 2009, I was 18 years old and starting my first job. I used PHP’s serialize to store a large blob of data in the database because I didn’t want to create individual columns for every leaf. I was nicknamed “Robert serialized-data-in-the-database Ross,” a nickname that still makes me stare off into the distance.
In Cadenya’s case, though, the spec field is always stored in the database. You might have noticed it in the tool_sets schema above.
At a high level, a workspace resource always looks like this:
{
"metadata": {}, // ResourceMetadata
"spec": {}, // ToolSetSpec, AgentSpec, SecretSpec, etc.
"info": {} // ToolSetInfo (calculated values)
}
A resource’s spec contains mostly nonrelational data. It’s loosely (or mostly?) based on the Kubernetes spec field in its manifests.
Choose wisely: JSONB or bytes?
I faced a hard decision when putting spec (or any Protobuf message) in the database: Which column type should I use?
Oh, the choices…
- I can use
jsonband slap aGINindex on it so I can perform (insane) select queries against it. - I can store hyper-efficient raw bytes, lose all queryability, and implement custom database adapters.
I chose bytes based on the Theory of Constraints. If I can’t select against the column, I won’t put anything I need to select on into it; I’ll be forced to promote that data to its own dedicated column. It also forces me to change the Protobuf definitions in a backward-compatible way. Once the bytes are in the database, there’s no changing the message definition incompatibly. If I do, deserialization at the API layer will fail. Bad news bears.
The Ent schema for a resource therefore looks like this:
func (ToolSet) Fields() []ent.Field {
return []ent.Field{
field.Other("spec", &apiv1.ToolSetSpecValue{}).SchemaType(map[string]string{
dialect.Postgres: "bytea",
}),
}
}
The package I made to generate valid Protobuf database types is open source, too. It generates a separate type that implements the sql.Scanner and driver.Value interfaces.
Configuring Envoy for transcoding
Envoy’s configuration almost becomes part of the API design because of some of the flags I chose to set. Here is most of the Envoy configuration that powers the api.cadenya.com endpoints.
static_resources:
listeners:
- name: listener_0
address:
socket_address:
address: 0.0.0.0
port_value: ${PORT}
per_connection_buffer_limit_bytes: 1048576
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
http_filters:
- name: envoy.filters.http.health_check
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.health_check.v3.HealthCheck
pass_through_mode: false
headers:
- name: ":path"
string_match:
exact: "/ready"
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.grpc_json_transcoder
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_json_transcoder.v3.GrpcJsonTranscoder
proto_descriptor: ${PROTO_DESCRIPTOR}
services:
- cadenya.api.v1.AccountService
- cadenya.api.v1.AgentScheduleService
- cadenya.api.v1.AgentService
- cadenya.api.v1.AgentVariationService
- cadenya.api.v1.AIProviderKeyService
- cadenya.api.v1.APIKeyService
- cadenya.api.v1.GlobalAPIKeyService
- cadenya.api.v1.MemoryService
- cadenya.api.v1.ModelService
- cadenya.api.v1.ObjectiveService
- cadenya.api.v1.ProfilesService
- cadenya.api.v1.SearchService
- cadenya.api.v1.TenantService
- cadenya.api.v1.ToolService
- cadenya.api.v1.UploadService
- cadenya.api.v1.WidgetService
- cadenya.api.v1.WidgetSessionService
- cadenya.api.v1.WorkspaceAdminService
- cadenya.api.v1.WorkspaceSecretService
- cadenya.api.v1.WorkspaceService
auto_mapping: false
convert_grpc_status: true
max_request_body_size: 67108864
max_response_body_size: 67108864
case_insensitive_enum_parsing: true
ignore_unknown_query_parameters: true
# Decode '+' in query parameters to a space. URL search
# boxes form-encode a space as '+', so without this a
# multi-word query like "Faker MCP" arrives as
# "Faker+MCP" and a prefix/substring match finds nothing.
query_param_unescape_plus: true
print_options:
always_print_primitive_fields: true
add_whitespace: true
clusters:
# STRICT_DNS + a headless Service: DNS returns every ready pod IP and
# Envoy round-robins requests across them. LOGICAL_DNS kept a single
# endpoint (the ClusterIP VIP), pinning each Envoy's HTTP/2 connection to
# one kube-proxy-chosen pod, so HPA-added pods received no traffic.
- name: grpc_backend
connect_timeout: 5s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
load_assignment:
cluster_name: grpc_backend
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: ${GRPC_SERVICE_HOST}
port_value: ${GRPC_SERVICE_PORT}
You should absolutely read the Envoy transcoder configuration options all the way through. There are a lot of knobs you can twist to your liking.
SDK generation
Cadenya is a developer tool, and any developer tool needs SDKs. A primary goal of using Protobuf was to export an OpenAPI specification that would translate cleanly into those SDKs.
Gnostic is a protoc plugin that takes the Protobuf descriptor and generates an OpenAPI specification from it. It’s extremely easy to add to your buf.gen.yaml file.
# buf.gen.yaml
version: v2
clean: true
plugins:
- remote: buf.build/community/google-gnostic-openapi:v0.7.1
out: gen/oapi
opt:
- naming=json
- enum_type=string
Generating the OpenAPI specification then becomes as easy as:
buf generate
# writes to gen/oapi/openapi.yaml
GitHub Actions then publishes the OpenAPI specification to https://openapi.cadenya.com/api-spec.yml when changes are merged into main. That file becomes the input for generating new SDKs.
Generating an SDK
SDKs are generated using a Rust-based CLI called Redwood. Redwood reads the OpenAPI specification into an IR (intermediate representation), which can then be passed to different language adapters to generate an entire SDK.
There are products on the market that do this, but they are expensive for what they do. I decided I could roll my own CLI generator using Claude and Codex. Redwood is open source.
Redwood can take an OpenAPI specification and give you a fully featured SDK with a single command:
redwood --spec ./gen/oapi/openapi.yaml --language go
For example, it generates list parameters for the tool set endpoint like this:
// Code generated by redwood. DO NOT EDIT.
package cadenya
import (
"context"
"fmt"
"net/url"
"strconv"
)
type ToolSetListParams struct {
WorkspaceID string `json:"workspaceId"`
Limit *int32 `json:"limit,omitempty"`
Cursor *string `json:"cursor,omitempty"`
Prefix *string `json:"prefix,omitempty"`
Query *string `json:"query,omitempty"`
State *ToolServiceListToolSetsState `json:"state,omitempty"`
Labels *string `json:"labels,omitempty"`
SortOrder *string `json:"sortOrder,omitempty"`
IncludeInfo *bool `json:"includeInfo,omitempty"`
}
It also generates the create tool set endpoint:
func (s *toolSetsService) Create(ctx context.Context, params *ToolSetCreateParams, opts ...RequestOption) (*ToolSet, error) {
if params == nil {
params = &ToolSetCreateParams{}
}
segWorkspaceID, err := pathSegment("workspaceId", params.WorkspaceID)
if err != nil {
return nil, err
}
path := fmt.Sprintf("/v1/workspaces/%s/tool_sets", segWorkspaceID)
body := map[string]any{}
if params.Metadata != nil {
body["metadata"] = params.Metadata
}
if params.Spec != nil {
body["spec"] = params.Spec
}
var out ToolSet
if err := s.core.do(ctx, "POST", path, nil, body, &out, opts...); err != nil {
return nil, err
}
return &out, nil
}
Redwood can generate a CLI, too:
redwood --spec ./gen/oapi/openapi.yaml --language cli
You can peruse the Cadenya SDKs on GitHub:
Originally, I started with Stainless.com, but it was acquired (yay!) and then shut down (boo!). The alternatives were subpar.
Mintlify
Not sponsored, but Mintlify has built the best modern documentation product I’ve come across. Its support for OpenAPI is excellent. I was able to hand Mintlify the generated OpenAPI specification and, bada bing, documentation!
Wrapping Up
This is not how the Cadenya API started. It started with flat messages and different pagination, and its message names changed dozens of times. But after a year of building Cadenya, these choices enable rapid changes to the API and backend because they’re consistent. And consistency (even for moderately bad choices) will trump any “perfect” design.
Thanks for reading.
Further reading
Grow wherever AI goes next.
Start shipping agents that are equipped to evolve.