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.

bashclone, configure, run
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 -d

Open 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.

bash.env — enabling the control plane
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 -d

These are the values the rest of the page uses:

bashthe addresses and credentials used throughout
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 .env

2. 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).

bashPOST /api/auth/login
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.

bashPOST /api/tenants/{tenantId}/api-keys
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:

bashcheck you can reach the data plane
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.

bashReputationAdvisor/Register
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 result

4. 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.

bashReputationAdvisor/Acquire
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.

bashReputationAdvisor/Report — success
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
bashReputationAdvisor/Report — failure
# 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/Report

Pick 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.

bashReputationAdvisor/Release
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.

javaWorker.java
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.

typescriptworker.ts
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.

bashGET /api/pools/resources and GET /api/events
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? The Register call did not reach the tenant you are reading — check that the API key and the JWT belong to the same tenant.
  • contexts: 0 on the row? You registered but never reported. A cell is created by Report, not by Acquire.
  • 401 on 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.
  • UNAVAILABLE from grpcurl? Nothing is listening on 9093. Check docker compose ps; the app publishes that port on 127.0.0.1 only, 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:9093 becomes 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 digle117@gmail.com — 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-cloudcompose.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.