Quickstart
Issue an API key, register a resource, acquire the healthiest one for a context, and report what happened — the full round trip.
1. Start the stack
The service is one docker compose file: the app (REST on 8083, gRPC on 9093), PostgreSQL, the dashboard, and a Caddy reverse proxy that serves both on a single origin at :8080. Flyway migrates the schema on first boot, so there is no separate setup step.
git clone https://github.com/PreAgile/reputation-pool-cloud
cd reputation-pool-cloud
cp .env.example .env # REPUTATION_POOL_API_KEY has no default — compose refuses to start without it
docker compose up --build -dOpen http://localhost:8080 and the dashboard is there. The REST control plane needs one more step: uncomment the three REPUTATION_POOL_ADMIN_* lines in .env. Leaving them unset is fail-closed on purpose — the admin console stays disabled and every /api/** call is rejected, while the gRPC data plane keeps working.
REPUTATION_POOL_ADMIN_USERNAME=admin
REPUTATION_POOL_ADMIN_PASSWORD=change-me-local-dev
# HS256 needs a 256-bit key: anything shorter than 32 bytes fails fast at startup.
REPUTATION_POOL_ADMIN_JWT_SECRET=0123456789abcdef0123456789abcdef
# Then re-create the container. Not `docker compose restart` — that reuses the container
# with the environment it was created with, so the new variables would appear to do nothing.
docker compose up -dThese are the values the rest of the page uses:
export RP_ORIGIN=http://localhost:8080 # control plane + dashboard, one origin (Caddy)
export RP_GRPC=localhost:9093 # data plane — loopback only, by design
export RP_TENANT=default # the tenant compose seeds on startup
export RP_API_KEY=local-dev-key # REPUTATION_POOL_API_KEY from your .env2. Get an API key
You already have one. On startup the app seeds REPUTATION_POOL_API_KEY as an active key for the default tenant, so $RP_API_KEY authenticates gRPC calls immediately. Change the variable and restart and the old key is revoked in the same step — the env var is the single source of truth for that one bootstrap key.
For anything beyond a first run you want a key per worker, and those come from the control plane. Log in for a token first; it expires after expiresInSeconds (one hour by default).
curl -sS -X POST "$RP_ORIGIN/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"change-me-local-dev"}'
# {"token":"eyJhbGciOiJIUzI1NiJ9…","tokenType":"Bearer","expiresInSeconds":3600}
export RP_JWT=eyJhbGciOiJIUzI1NiJ9…Now mint a key. The rawToken in the response is the only time the key material is ever available — it is stored as a hash, not encrypted, so it cannot be read back later. Put it straight into your secret store.
curl -sS -X POST "$RP_ORIGIN/api/tenants/$RP_TENANT/api-keys" \
-H "Authorization: Bearer $RP_JWT" \
-H 'Content-Type: application/json' \
-d '{"label":"worker-01"}'
# 201 Created
# {
# "id": "5f1c…-…",
# "rawToken": "rp_9Q3xK7bT…", <-- shown once, never again
# "label": "worker-01",
# "prefix": "rp_9Q3xK7bT", <-- what listings show
# "createdAt": "2026-07-29T09:12:44Z"
# }
export RP_API_KEY=rp_9Q3xK7bT…The dashboard's API keys screen does the same thing with a button. Either way, the tenantId in the path must be the tenant your token is bound to — a key request aimed at another tenant is rejected with 403 regardless of whether that tenant exists.
3. Register your resources
A resource is a kind (PROXY, ACCOUNT, or SESSION) plus an opaque value you choose — a proxy endpoint, an account id, a session handle. Registration is idempotent, so re-registering on every worker boot is fine and is the usual pattern.
The data plane is gRPC, so the requests below use grpcurl rather than curl. Server reflection is on, so grpcurl can discover the service by itself — no .proto file to fetch. The interceptor guards reflection too, so the key is required even to list:
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" "$RP_GRPC" list
# io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor <-- plus health and reflection-plaintext is correct here and not a shortcut: the port is on loopback with no TLS in front of it. Drop the flag only when you have actually put a TLS terminator there.
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
-d '{"resource":{"kind":"PROXY","value":"proxy-1.example.net:8080"}}' \
"$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Register
# {} <-- RegisterResponse is empty; the call succeeding is the result4. Acquire for a context
A context is a string naming what you are about to do — checkout-us, search-eu, one per destination or workload that can burn a resource independently. You pass it to Acquire and the pool returns the healthiest resource for that context, leased exclusively to you.
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
-d '{"context":{"value":"checkout-us"}}' \
"$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Acquire
# {
# "granted": true,
# "lease": {
# "resource": {"kind":"PROXY","value":"proxy-1.example.net:8080"},
# "context": {"value":"checkout-us"},
# "token": "1", <-- int64 is a string in proto3 JSON
# "leasedAt": "2026-07-29T09:13:02Z",
# "expiresAt": "2026-07-29T09:13:32Z" <-- 30s lease TTL by default
# }
# }5. Use the resource, then report the outcome
Do your work with lease.resource, then tell the pool what happened. This is the call that moves reputation: a success nudges the score up, a failure pushes it down by an amount that depends on the failure type and, past the threshold, benches the resource for a cooldown.
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
-d '{
"resource": {"kind":"PROXY","value":"proxy-1.example.net:8080"},
"context": {"value":"checkout-us"},
"outcome": {"success":{"latency":"0.412s"}}
}' \
"$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Report# type: CONNECTION_RESET | TLS_HANDSHAKE | TIMEOUT | BLOCKED | SLOW
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
-d '{
"resource": {"kind":"PROXY","value":"proxy-1.example.net:8080"},
"context": {"value":"checkout-us"},
"outcome": {"failure":{"type":"BLOCKED","latency":"1.2s"}}
}' \
"$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/ReportPick the failure type honestly — it is not cosmetic. BLOCKED costs 30 score points and starts an hour of cooldown; SLOW costs 2 and starts thirty seconds. Reporting everything as BLOCKED will empty your pool. The exact numbers are in Concepts.
Finally, hand the resource back with Release so another worker can take it. Releasing is not required for correctness — a lease expires on its own after its TTL — but releasing promptly is what keeps the pool busy instead of waiting out TTLs. If your work outlives the TTL, call Renew.
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
-d '{"lease":{"resource":{"kind":"PROXY","value":"proxy-1.example.net:8080"},
"context":{"value":"checkout-us"},"token":"1",
"leasedAt":"2026-07-29T09:13:02Z","expiresAt":"2026-07-29T09:13:32Z"}}' \
"$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Release
# {"released": true}The same loop in Java
The generated stubs come from the published io.github.preagile:reputation-pool-grpc artifact, so there is no codegen step on your side. Wire types are nested under one outer class (AdvisorProto) on purpose — their simple names collide with the engine's domain types.
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.AcquireRequest;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.AcquireResponse;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.Context;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.FailureType;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.LeaseHandle;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.Outcome;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.RegisterRequest;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.ReleaseRequest;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.ReportRequest;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.ResourceId;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.ResourceKind;
import io.github.preagile.reputationpool.grpc.v1.ReputationAdvisorGrpc;
import io.grpc.ChannelCredentials;
import io.grpc.Grpc;
import io.grpc.InsecureChannelCredentials;
import io.grpc.ManagedChannel;
import io.grpc.Metadata;
import io.grpc.stub.MetadataUtils;
final class Worker {
private final ReputationAdvisorGrpc.ReputationAdvisorBlockingStub advisor;
/**
* Loopback data plane: InsecureChannelCredentials. Behind a TLS terminator this becomes
* TlsChannelCredentials.create() and nothing else in this class changes.
*/
Worker(String target, String apiKey) {
var headers = new Metadata();
headers.put(Metadata.Key.of("x-api-key", Metadata.ASCII_STRING_MARSHALLER), apiKey);
ChannelCredentials credentials = InsecureChannelCredentials.create();
ManagedChannel channel = Grpc.newChannelBuilder(target, credentials).build();
this.advisor = ReputationAdvisorGrpc.newBlockingStub(channel)
.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(headers));
}
void runOnce() {
var proxy = ResourceId.newBuilder()
.setKind(ResourceKind.PROXY)
.setValue("proxy-1.example.net:8080")
.build();
var context = Context.newBuilder().setValue("checkout-us").build();
advisor.register(RegisterRequest.newBuilder().setResource(proxy).build()); // idempotent
AcquireResponse acquired = advisor.acquire(AcquireRequest.newBuilder().setContext(context).build());
if (!acquired.getGranted()) {
return; // nothing eligible right now — back off and retry
}
LeaseHandle lease = acquired.getLease();
long startedAt = System.nanoTime();
try {
useProxy(lease.getResource().getValue()); // your work
report(lease, Outcome.newBuilder()
.setSuccess(Outcome.Success.newBuilder().setLatency(elapsed(startedAt)))
.build());
} catch (java.io.IOException failed) {
report(lease, Outcome.newBuilder()
.setFailure(Outcome.Failure.newBuilder()
.setType(FailureType.CONNECTION_RESET)
.setLatency(elapsed(startedAt)))
.build());
} finally {
advisor.release(ReleaseRequest.newBuilder().setLease(lease).build());
}
}
private void report(LeaseHandle lease, Outcome outcome) {
advisor.report(ReportRequest.newBuilder()
.setResource(lease.getResource())
.setContext(lease.getContext())
.setOutcome(outcome)
.build());
}
/** google.protobuf.Duration from a nanosecond stopwatch reading. */
private static com.google.protobuf.Duration elapsed(long startedAtNanos) {
long nanos = System.nanoTime() - startedAtNanos;
return com.google.protobuf.Duration.newBuilder()
.setSeconds(nanos / 1_000_000_000L)
.setNanos((int) (nanos % 1_000_000_000L))
.build();
}
}The same loop in TypeScript
There is no published JS client yet, so load advisor.proto at runtime with @grpc/proto-loader. keepCase: false gives you the same lowerCamelCase field names the JSON examples above use. The proto file ships inside the published reputation-pool-grpc artifact and lives in the engine repository.
import { credentials, loadPackageDefinition, Metadata, type ServiceClientConstructor } from "@grpc/grpc-js";
import { loadSync } from "@grpc/proto-loader";
// proto-loader hands back a plain object nested by proto package segment, typed only as GrpcObject.
// Declare the one path you use rather than casting the whole tree away — the cast is what makes a
// snippet like this stop compiling the moment you touch it.
interface AdvisorPackage {
io: {
github: {
preagile: {
reputationpool: { grpc: { v1: { ReputationAdvisor: ServiceClientConstructor } } };
};
};
};
}
/** Your work. Replace this with the request you actually make through the leased resource. */
declare function useProxy(endpoint: string): Promise<void>;
const definition = loadSync("advisor.proto", { keepCase: false, defaults: true, longs: String });
const advisorPackage = loadPackageDefinition(definition) as unknown as AdvisorPackage;
const { ReputationAdvisor } = advisorPackage.io.github.preagile.reputationpool.grpc.v1;
// Loopback data plane: plaintext. Point this at a TLS-terminating endpoint and it becomes
// credentials.createSsl() — nothing else in this file changes.
const advisor = new ReputationAdvisor(process.env.RP_GRPC ?? "localhost:9093", credentials.createInsecure());
const auth = new Metadata();
auth.set("x-api-key", process.env.RP_API_KEY ?? "");
const call = <T>(method: string, request: unknown): Promise<T> =>
new Promise((resolve, reject) => {
advisor[method](request, auth, (error: Error | null, response: T) =>
error === null ? resolve(response) : reject(error),
);
});
// google.protobuf.Duration accepts the "12.345s" JSON form.
const seconds = (ms: number): string => (ms / 1000).toFixed(3) + "s";
interface LeaseHandle {
token: string;
leasedAt: string;
expiresAt: string;
}
export async function runOnce(): Promise<void> {
const resource = { kind: "PROXY", value: "proxy-1.example.net:8080" };
const context = { value: "checkout-us" };
await call("Register", { resource }); // idempotent
const acquired = await call<{ granted: boolean; lease?: LeaseHandle }>("Acquire", { context });
if (!acquired.granted || acquired.lease === undefined) {
return; // nothing eligible right now — back off and retry
}
const lease = acquired.lease;
const startedAt = Date.now();
try {
await useProxy(resource.value);
await call("Report", {
resource,
context,
outcome: { success: { latency: seconds(Date.now() - startedAt) } },
});
} catch {
await call("Report", {
resource,
context,
outcome: { failure: { type: "TIMEOUT", latency: seconds(Date.now() - startedAt) } },
});
} finally {
await call("Release", { lease: { resource, context, ...lease } });
}
}
6. Verify it landed
Read the pool back over the control plane. Your resource should be there with one cell per context you have reported on, and the events feed should show the lease and any cooldown.
curl -sS "$RP_ORIGIN/api/pools/resources" -H "Authorization: Bearer $RP_JWT"
curl -sS "$RP_ORIGIN/api/events?limit=10" -H "Authorization: Bearer $RP_JWT"- Nothing in
resources? TheRegistercall did not reach the tenant you are reading — check that the API key and the JWT belong to the same tenant. contexts: 0on the row? You registered but never reported. A cell is created byReport, not byAcquire.401on every REST call? Either the token expired — log in again — or the three admin variables are not set, which disables the console entirely. See REST API reference for the full error table.UNAVAILABLEfrom grpcurl? Nothing is listening on9093. Checkdocker compose ps; the app publishes that port on127.0.0.1only, so it is unreachable from another machine by design.
What changes on a hosted data plane
Nothing in the loop. The RPCs, the message shapes, the API key header, and the reputation model are the engine's, and this service consumes the engine as a published dependency rather than a fork — the same code runs in both places. Moving a worker from your stack to a hosted one is two edits:
- the target address —
localhost:9093becomes whatever the hosted endpoint is; - the channel credentials —
-plaintext/InsecureChannelCredentials/credentials.createInsecure()become their TLS counterparts.
What does not exist yet is the endpoint itself. There is no published hostname, port, or TLS contract for a hosted data plane, and this page will not invent one. If that is what stands between you and using this, say so at [email protected] — it is a known gap, and knowing who needs it is what decides when it gets built. Reputation state does not transfer between deployments today either; pools warm up again from live traffic.
Everything on this page came from the deployment files in PreAgile/reputation-pool-cloud — compose.yaml, compose.prod.yaml, and Caddyfile.prod. If you want to check the claim about the port binding rather than take it on trust, that is where to look.