| name | grpc |
| description | Guidelines for working with the Connect RPC (gRPC) API. Apply when modifying proto definitions, implementing server handlers, or creating API clients. |
gRPC API Development Guide
XAGENT uses Connect RPC for its API, providing protocol flexibility (gRPC, gRPC-Web, Connect protocol) over HTTP.
Architecture Overview
- Proto definitions:
proto/xagent/v1/xagent.proto
- Generated Go code:
internal/proto/xagent/v1/ (gitignored)
- Generated TypeScript code:
webui/src/gen/ (committed)
- Server implementation:
internal/server/server.go
- Go client package:
internal/xagentclient/
- TypeScript client: Uses
@connectrpc/connect-query with TanStack Query
Adding a New RPC
1. Define the proto messages and service method
Edit proto/xagent/v1/xagent.proto:
service XAgentService {
// ... existing RPCs ...
rpc MyNewMethod(MyNewRequest) returns (MyNewResponse);
}
message MyNewRequest {
int64 id = 1;
string name = 2;
}
message MyNewResponse {
bool success = 1;
}
2. Generate the code
mise run generate
This runs:
go tool buf generate - generates protobuf messages and Connect client/server interfaces (Go)
go generate ./... - runs any other code generators
For the frontend TypeScript client, run:
cd webui && pnpm run generate
This generates:
webui/src/gen/xagent/v1/xagent_pb.ts - TypeScript types and message schemas
webui/src/gen/xagent/v1/xagent-XAgentService_connectquery.ts - Connect-Query method exports
3. Implement the server handler
The server embeds UnimplementedXAgentServiceHandler, which provides default "not implemented" responses for all RPCs. Override the method in internal/server/server.go:
func (s *Server) MyNewMethod(ctx context.Context, req *xagentv1.MyNewMethodRequest) (*xagentv1.MyNewMethodResponse, error) {
if req.Id == 0 {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("id is required"))
}
result, err := s.tasks.DoSomething(req.Id)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
return &xagentv1.MyNewMethodResponse{
Success: true,
}, nil
}
4. Use Connect error codes
Always use appropriate Connect error codes:
import "connectrpc.com/connect"
connect.CodeInvalidArgument
connect.CodeNotFound
connect.CodeInternal
connect.CodeUnimplemented
connect.CodeAlreadyExists
connect.CodePermissionDenied
Client Usage
Creating a client
import "github.com/icholy/xagent/internal/xagentclient"
client := xagentclient.New("http://localhost:6464")
client := xagentclient.New("unix:///xagent/socket")
Making RPC calls
import xagentv1 "github.com/icholy/xagent/internal/proto/xagent/v1"
resp, err := client.GetTask(ctx, &xagentv1.GetTaskRequest{Id: 123})
if err != nil {
}
task := resp.Task
resp, err := client.CreateTask(ctx, &xagentv1.CreateTaskRequest{
Name: "My Task",
Workspace: "default",
Instructions: []*xagentv1.Instruction{
{Text: "Do something", Url: "https://example.com"},
},
})
Data Conversion Patterns
The server converts between proto messages and store domain models:
instructions := make([]store.Instruction, len(req.Instructions))
for i, inst := range req.Instructions {
instructions[i] = store.Instruction{
Text: inst.Text,
URL: inst.Url,
}
}
func taskToProto(t *store.Task) *xagentv1.Task {
return &xagentv1.Task{
Id: t.ID,
Name: t.Name,
Status: string(t.Status),
CreatedAt: timestamppb.New(t.CreatedAt),
}
}
Protobuf Best Practices
Field naming
- Use snake_case in proto files:
parent_id, created_at
- Generated Go uses PascalCase:
ParentId, CreatedAt
Timestamps
import "google/protobuf/timestamp.proto";
message Task {
google.protobuf.Timestamp created_at = 7;
}
Convert with timestamppb:
import "google.golang.org/protobuf/types/known/timestamppb"
protoTime := timestamppb.New(time.Now())
goTime := protoTimestamp.AsTime()
Repeated fields (lists)
message ListTasksResponse {
repeated Task tasks = 1;
}
Optional fields
In proto3, all fields are optional by default. Empty/zero values are not serialized.
Maps
message McpServer {
map<string, string> env = 4;
}
HTTP/JSON API Access
Connect RPC endpoints are accessible via HTTP POST with JSON:
curl -X POST http://localhost:6464/xagent.v1.XAgentService/ListTasks \
-H "Content-Type: application/json" \
-d '{"statuses": ["pending", "running"]}'
curl -X POST http://localhost:6464/xagent.v1.XAgentService/GetTask \
-H "Content-Type: application/json" \
-d '{"id": 123}'
URL pattern: /{package}.{service}/{method}
Testing
Unit testing handlers
Mock the client interface for testing:
func TestMyHandler(t *testing.T) {
mockClient := &ClientMock{
GetTaskFunc: func(ctx context.Context, req *xagentv1.GetTaskRequest) (*xagentv1.GetTaskResponse, error) {
return &xagentv1.GetTaskResponse{
Task: &xagentv1.Task{Id: req.Id, Name: "Test"},
}, nil
},
}
}
Buf Configuration
buf.yaml
Configures the protobuf module and linting rules:
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
buf.gen.yaml
Specifies code generation plugins:
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: internal/proto
opt: paths=source_relative
- remote: buf.build/connectrpc/go
out: internal/proto
opt:
- paths=source_relative
- simple
Common Patterns
Logging in handlers
s.log.Info("task created", "id", task.ID, "workspace", task.Workspace)
Handling not found
task, err := s.tasks.Get(req.Id)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
}
Batch operations
func (s *Server) UploadLogs(ctx context.Context, req *xagentv1.UploadLogsRequest) (*xagentv1.UploadLogsResponse, error) {
for _, entry := range req.Entries {
log := &model.Log{
TaskID: req.TaskId,
Type: entry.Type,
Content: entry.Content,
}
if err := s.logs.Create(ctx, log); err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
}
return &xagentv1.UploadLogsResponse{}, nil
}
TypeScript Client (Web UI)
The web UI uses @connectrpc/connect-query for type-safe API calls with TanStack Query integration.
Setup
The transport is configured in webui/src/lib/transport.ts and provided via TransportProvider in main.tsx. This is already set up.
Fetching data with useQuery
Import the generated method descriptor and use it with useQuery from @connectrpc/connect-query:
import { useQuery } from '@connectrpc/connect-query'
import { listTasks } from '@/gen/xagent/v1/xagent-XAgentService_connectquery'
function TaskList() {
const { data, isLoading, error } = useQuery(listTasks, {
statuses: ['pending', 'running'],
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<ul>
{data?.tasks.map(task => (
<li key={String(task.id)}>{task.name}</li>
))}
</ul>
)
}
Mutations with useMutation
import { useMutation, useQueryClient } from '@connectrpc/connect-query'
import { createTask, listTasks } from '@/gen/xagent/v1/xagent-XAgentService_connectquery'
import { create } from '@bufbuild/protobuf'
import { InstructionSchema } from '@/gen/xagent/v1/xagent_pb'
function CreateTaskButton() {
const queryClient = useQueryClient()
const mutation = useMutation(createTask, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [listTasks.service.typeName] })
},
})
const handleCreate = () => {
mutation.mutate({
name: 'New Task',
workspace: 'default',
instructions: [
create(InstructionSchema, { text: 'Do something', url: '' }),
],
})
}
return <button onClick={handleCreate}>Create Task</button>
}
Important: bigint handling
Protobuf int64 fields are generated as TypeScript bigint. When displaying or using these values:
<span>Task ID: {String(task.id)}</span>
const numId = Number(task.id)
const request = { id: task.id }
TypeScript types
The generated types are in webui/src/gen/xagent/v1/xagent_pb.ts:
import type { Task, Event, TaskLink } from '@/gen/xagent/v1/xagent_pb'
function TaskCard({ task }: { task: Task }) {
return (
<div>
<h3>{task.name}</h3>
<p>Status: {task.status}</p>
<p>Workspace: {task.workspace}</p>
</div>
)
}
webui/buf.gen.yaml
The TypeScript code generation is configured in webui/buf.gen.yaml:
version: v2
inputs:
- directory: ../proto
plugins:
- local: protoc-gen-es
out: src/gen
opt: target=ts
- local: protoc-gen-connect-query
out: src/gen
opt: target=ts