JSON-RPC evolved for AI tools and local systems.
RO-JRPC is a backward-compatible extension to JSON-RPC 2.0 that adds structured routing fields to request messages:
| Field | Purpose |
|---|---|
resource |
Logical entity (user, task, repo) |
target |
Specific instance of resource (42, "abc") |
verb |
Action (get, create, cancel) |
subresource |
Nested entity owned by resource (repo → issue) |
parent |
Specific instance of resource that owns the subresource |
Every RO-JRPC request is a valid JSON-RPC 2.0 request. Nothing breaks.
JSON-RPC method strings already encode this structure informally:
user.create
task.cancel
tool.execute
RO-JRPC makes it explicit — enabling:
- ✅ Structured authorization (
allow user:create,deny file:delete) - ✅ Automatic capability discovery via
rpc.describe - ✅ Clean router APIs without string parsing
- ✅ Better tooling for AI agents, CLIs, and IPC systems
- ✅ Code generation and analytics
- ✅ Async result routing via resource-oriented result messages
{
"jsonrpc": "2.0",
"method": "user.get",
"resource": "user",
"target": "42",
"verb": "get",
"id": 1
}{
"jsonrpc": "2.0",
"result": { "id": "42", "name": "Alice" },
"cache": { "max-age": 60 },
"id": 1
}npm install ro-jrpcimport { createRouter, buildRequest, buildResult } from 'ro-jrpc';
const router = createRouter();
router.resource('user').verb('get', async ({ target }) => {
return { id: target, name: 'Alice' };
});
router.resource('user').verb('create', async ({ params }) => {
return { id: '99', ...params };
});
// Handle a request
const response = await router.handle({
jsonrpc: '2.0',
method: 'user.get',
resource: 'user',
target: '42',
verb: 'get',
id: 1,
});Sub-resources use a chained .subresource() call on the resource builder:
router.resource('repo').subresource('issue').verb('get', async ({ parent, target }) => {
return { repoId: parent, issueId: target };
});
router.resource('project').subresource('task').verb('list', async ({ parent }) => {
return [{ id: '1', projectId: parent }];
});See implementations/js/ for the full reference implementation.
use ro_jrpc::RequestBuilder;
// Flat resource
let req = RequestBuilder::new("user", "get")
.target("42")
.id(serde_json::json!(1))
.build();
assert_eq!(req.method, "user.get");
// Sub-resource
let req = RequestBuilder::new("repo", "get")
.subresource("issue")
.parent("99")
.target("7")
.id(serde_json::json!(2))
.build();
assert_eq!(req.method, "repo.issue.get");See implementations/rust-stub/ for the Rust crate stub.
Both client and server manage their own cache independently — no negotiation is required for the basic case. The natural cache key is resource + verb + target (plus subresource + parent when present).
Server hints TTL to client via cache on the response:
{
"jsonrpc": "2.0",
"result": { "id": "42", "name": "Alice" },
"cache": { "max-age": 60 },
"id": 1
}Client bypasses server cache via cache on the request:
{
"jsonrpc": "2.0",
"method": "user.get",
"resource": "user",
"verb": "get",
"target": "42",
"cache": "no-cache",
"id": 1
}Both fields are optional. Silence means each side does whatever it likes locally.
Async result messages are themselves resource-oriented — the receiver routes them using the same router as any other message. The result resource does not need to match the originating request's resource, allowing long-running operations to respond as a job or any other resource.
Two reserved verbs carry async results:
| Verb | Meaning |
|---|---|
yield |
Intermediate result; more messages expected |
return |
Final result; no further messages will be sent |
The result object should include a status field:
| Status | Meaning |
|---|---|
accepted |
Received and queued, not yet started |
pending |
In progress, more results expected |
done |
Completed successfully |
error |
Failed; no further messages will be sent |
Use request_id to correlate a result back to a specific originating request, especially when the result is delivered on a different channel or as a different resource:
// Originating request
const req = buildRequest({
resource: 'attachment',
verb: 'create',
params: { filename: 'report.pdf' },
id: 'req-001',
});
// Intermediate result delivered later as a job resource
const progress = buildResult({
resource: 'job',
verb: 'yield',
target: 'job-123',
result: { status: 'pending', progress: 60, stage: 'scanning' },
request_id: 'req-001',
});
// Final result
const final = buildResult({
resource: 'job',
verb: 'return',
target: 'job-123',
result: { status: 'done', attachmentId: 'att-456' },
request_id: 'req-001',
});For session or stream events where there is no single originating request, omit request_id and scope by target:
// Intermediate session event
const chunk = buildResult({
resource: 'session',
verb: 'yield',
target: 'session-9',
result: { status: 'pending', content: 'Thinking...' },
});
// Final session result
const done = buildResult({
resource: 'session',
verb: 'return',
target: 'session-9',
result: { status: 'done', content: 'Here is your answer.' },
});📄 spec.md — Full specification
methodMUST equal<resource>.<verb>(or<resource>.<subresource>.<verb>) when structured fields are presenttargetSHOULD NOT be embedded inmethodsubresourceandparentMUST NOT appear withoutresourceparentMUST NOT appear withoutsubresource- Mismatched
method/resource/verbSHOULD be rejected with-32600 yieldandreturnare reserved for async result messages (server → client)- Servers SHOULD support
rpc.describefor discovery - Servers MUST NOT trust client-supplied
meta
{
"jsonrpc": "2.0",
"method": "user.get",
"resource": "user",
"target": "42",
"verb": "get",
"id": 1
}{
"jsonrpc": "2.0",
"method": "repo.issue.get",
"resource": "repo",
"parent": "99",
"subresource": "issue",
"target": "7",
"verb": "get",
"id": 2
}{
"jsonrpc": "2.0",
"result": { "id": "7", "repoId": "99", "title": "Fix null pointer", "state": "open" },
"id": 2
}{
"jsonrpc": "2.0",
"method": "attachment.create",
"resource": "attachment",
"verb": "create",
"params": { "filename": "report.pdf" },
"id": "req-001"
}{
"jsonrpc": "2.0",
"method": "job.yield",
"resource": "job",
"verb": "yield",
"target": "job-123",
"result": { "status": "pending", "progress": 60, "stage": "scanning" },
"request_id": "req-001"
}{
"jsonrpc": "2.0",
"method": "job.return",
"resource": "job",
"verb": "return",
"target": "job-123",
"result": { "status": "done", "attachmentId": "att-456" },
"request_id": "req-001"
}{
"jsonrpc": "2.0",
"method": "session.yield",
"resource": "session",
"verb": "yield",
"target": "session-9",
"result": { "status": "pending", "content": "Thinking..." }
}{
"jsonrpc": "2.0",
"method": "session.return",
"resource": "session",
"verb": "return",
"target": "session-9",
"result": { "status": "done", "content": "Here is your answer." }
}More examples: subresources.json · examples/user-crud.json · examples/agent-tool-call.json · examples/notifications.json · examples/discovery.json
RO-JRPC is transport-agnostic. Use it over:
stdio/ pipes- Unix domain sockets
- TCP
- HTTP
- WebSocket
- Child-process IPC
RO-JRPC is not a replacement for JSON-RPC 2.0. It is JSON-RPC 2.0 with optional structured fields. Existing JSON-RPC 2.0 servers and clients continue to work unchanged.
- Open an issue to discuss proposed changes
- Spec changes require discussion in an issue before a PR
- Implementation PRs welcome for any language