All documents

Service API Reference

spicegrinder-icon

© 2026 Obsvra. This document describes SpiceGrinder and is provided to help you evaluate and use it. It is not a license to reproduce, adapt, or use this material to build a competing product or service. Full terms: the SpiceGrinder EULA.

ModelServiceApp

NAME

ModelServiceApp — Pro-tier HTTP service exposing SpiceGrinder models over a REST-ish API (unauthenticated)

SYNOPSIS

java -cp spicegrinder.jar com.obsvra.spicegrinder.pro.service.ModelServiceApp [options]

On a jpackage Pro install:

service [options]

Not present in a Free build — ModelServiceApp lives in the pro.service package. See also docker/docker-entrypoint.sh for the separate container-based distribution path.

DESCRIPTION

ModelServiceApp starts an HTTP server (default 127.0.0.1:8085) that lets a client register a model (optionally named), list what’s already registered, start/stop a live session for it, and generate observations synchronously or as a chunked stream — see ENDPOINTS below for the full route reference.

Blocks the main thread once started; runs until the process is terminated (Ctrl+C or a signal), at which point the shutdown hook stops the HTTP server, deregisters this process from the shared store, and exits cleanly.

Process cap, enforced before accepting any request: each running service process is a license-capacity unit (max.processes, default 1 — see CONFIGURATION). Before binding the HTTP listener at all, this process checks the shared store’s live process count against that cap and refuses to start — exiting with status 3 and an actionable message — if starting would exceed it. Raising the cap increases how many concurrent license sessions a deployment can consume; it’s one explicit setting, not a decision to make by accident.

OPTIONS

OptionMeaning
--port NHTTP port to bind (default 8085).
--bind ADDRInterface to listen on (default 127.0.0.1, loopback-only). A non-default value always prints a startup warning, so binding beyond loopback is an explicit operator choice — worded differently depending on whether auth.token (see CONFIGURATION below) is set, since a bearer token alone doesn’t protect a plain-HTTP connection either way. Containers pass 0.0.0.0 explicitly via docker/docker-entrypoint.sh; this is not a change to the default.
--db PATHOverride the shared store’s database base path (default ~/.spicegrinder/service/model-cache). Mainly for testing/isolation — e.g. running more than one independent instance side by side.
-h, --helpPrint usage and exit.
--versionPrint the version (e.g. 1.0.0 Alpha 1 (Build 1)) and exit.

EXIT STATUS

CodeMeaning
0Not reached in normal operation — the process blocks until externally terminated.
1Could not open the shared store, bind the configured --port/--bind, or register this process.
2Bad arguments — missing required value, or an unrecognized option.
3Refused to start: the process cap (max.processes) was already reached.

After an unclean crash (process killed rather than shut down cleanly), a restart attempt can hit exit code 3 even though no other process is actually running — the crashed process’s registration doesn’t get cleaned up until its heartbeat is recognized as stale. That window is governed by heartbeat.stale.after.seconds (default 90, see CONFIGURATION below); expect a restart to keep refusing for up to that long, then succeed on its own without intervention. Registered models and their generated data are unaffected either way — they live in the shared store, not the process. If a restart is refused well past that window, check for another live process instead (max.processes reached legitimately, or the registry itself is stuck).

EXAMPLES

Start on the default port, loopback-only:

java -cp spicegrinder.jar com.obsvra.spicegrinder.pro.service.ModelServiceApp

Start on a custom port, bound for container use:

service --port 9000 --bind 0.0.0.0

Run an isolated instance against its own database, for testing:

service --db /tmp/spicegrinder-test-service

Endpoints

MethodPathPurpose
POST/v1/modelsRegister a model (body = model file text, XML or JSON)
POST/v1/listList every registered model’s id, name, and metadata
POST/v1/models/{id}/startMake this model the process’s live/resident model
POST/v1/models/{id}/stopStop this model’s session (if it’s the live one)
POST/v1/models/{id}/generateSynchronous generate; full result in the response
POST/v1/models/{id}/streamChunked streaming generate; optional count
DELETE/v1/models/{id}Delete a registered model
GET/v1/generate/{generationId}/data?offset&limitPage back through a /generate result
GET/v1/components?category&componentList registered components and their parameters
GET/v1/allowlistsRead this process’s configured FlatFile/Database/ServiceCall allowlists

Error shape (any 4xx/5xx): {"error": "message"}.

POST /v1/models

Query params: keepResident (bool, default false) — pins this model’s resident graph against LRU eviction. name (string, optional) — a caller-supplied label, stored alongside the model and returned by POST /v1/list. Not unique — no uniqueness check is performed. Recommended convention: shared/library models keep their normal model name; personal dev variants use something like dev-<model_name>-<user>.

Response 201:

{"modelId": "m-<uuid>", "name": "widget-lib", "keepResident": false, "analysis": {
  "mode": "...", "nodeCount": N, "minDepth": N, "maxDepth": N, "score": N, "summary": "..."
}}

name is null in the response when the query param was omitted.

File paths in submitted models

A node like FlatFile (file parameter) resolves that path on this server process, not on the caller’s machine — FlatFile.configure() calls Path.of(fileName) directly against whatever filesystem this process sees, resolving a relative path against the process’s own working directory. There’s no client-side resolution step and no guarantee the caller and the server are even the same machine, so a file value that only makes sense on the machine that authored the model (e.g. a path under a developer’s home directory) will simply not exist from the server’s point of view, or worse, silently resolve to an unrelated file that happens to exist at that path on the server.

Use a path the server can actually reach: a location already staged on the server’s own disk, or a network-accessible path (shared/mounted filesystem, UNC path, etc.) that resolves to the same file from both where the model is authored and where the service runs. This is the same reason Import’s own path resolution (anchored to the model file’s directory) doesn’t have an equivalent for a model submitted as text with no file location of its own — there’s no “relative to the model” for the service to anchor against, only relative to its own working directory or an absolute/network path.

Absolute paths require flatFile.path.allowlist. A relative file always resolves — it can only reach somewhere the service’s own working directory already makes reachable, which the deploying org controls regardless of who’s submitting models. An absolute file names anywhere on the server’s filesystem directly, so it’s gated the same safe-by-default way Database/ServiceCall’s own URL allowlists already are: empty allowlist (the default) rejects every absolute file value outright. This matters specifically because SpiceGrinder’s whole auth model assumes a trusted caller — there’s no built-in authentication on ModelServiceApp itself; it’s the deploying org’s job to lock the service down (reverse proxy, network allowlists, etc.) if it’s ever exposed beyond a fully trusted environment. Database/ServiceCall already gave the org a way to constrain what an untrusted or semi-trusted model author can reach even without a locked-down network perimeter; FlatFile didn’t have the equivalent until now. See Component-Library-Reference.md for the full detail, including the macOS /tmp → /private/tmp symlink gotcha when writing allowlist entries.

POST /v1/list

No request body (reserved for future filtering; currently ignored). Lists every model in the shared store, not just this process’s own registrations.

Response 200:

{"models": [
  {"modelId": "m-<uuid>", "name": "widget-lib", "format": "XML",
   "registeredAt": "2026-08-16T10:46:19.077929Z", "nodeCount": N,
   "complexityScore": N, "keepResident": false},
  ...
]}

modelText is deliberately omitted — use POST /v1/models/{id}/generate or a direct ModelServiceStore.getModel lookup to retrieve a specific model’s full text.

POST /v1/models/{id}/start

Response 200: {"modelId": "...", "live": true, "continued": bool} — continued = was already this process’s live model.

POST /v1/models/{id}/stop

Response 200: {"modelId": "...", "live": false, "wasLive": bool}.

POST /v1/models/{id}/generate

Request body:

{"count": N, "seed": N, "noSeed": bool, "format": "csv|tsv|plain"}

count required, >= 0. format defaults to csv.

Response 200:

{"modelId": "...", "generationId": "g-<uuid>", "count": N, "seed": "...",
 "seedSource": "...", "rngContinued": bool, "format": "...", "durationMs": N,
 "data": "raw formatted rows"}

Seed/resident-graph resolution (no explicit seed, no noSeed): continues the process’s resident graph for this model if one exists; otherwise builds fresh and caches it, subject to max.resident.graphs (LRU eviction, keepResident pins exempt from eviction).

Large count values have a real memory ceiling this endpoint doesn’t warn you about in advance. /generate builds the entire response in memory before sending it — confirmed directly (2026-09-04): a 1,000,000-row request against a 1GB-RAM host failed with {"error":"generation failed: Java heap space"} (sometimes a clean 500, sometimes a raw connection drop depending on exactly when the OutOfMemoryError hit relative to response serialization) — not an OS-level crash, the process survives either way, just that one request. The identical request against /stream on the same host succeeded cleanly, because streaming sends chunks incrementally and never holds the whole response in memory at once. “SpiceGrinder runs on any hardware capable of Java 25” is true for running the engine — it is not a promise that /generate at an arbitrary row count fits in an arbitrary amount of heap. If you don’t know in advance that a request’s row count × row width comfortably fits in the process’s heap, use /stream instead — this is exactly the situation it exists for, not a fallback for exotic cases.

Sizing count for a single /generate call: start at 2,000-4,000 rows and adjust from there based on your model’s average row size — like stream.batch.size (above), it’s really bytes per call that matters, not row count, so a narrow (few-column) row can comfortably go higher than that and a wide/complex one should go lower. The reason this is a fairness concern as much as a performance one: /generate (like /stream) actually runs on this process’s single generation worker (see the class-level note on ModelServiceHandler — the same process-wide serialization that avoids a race on the shared random-number generator also means every call, regardless of caller, waits its turn on one worker), so one caller’s oversized request holds that worker — and every other concurrent caller’s request — for its full duration. A 2,000-4,000-row starting point keeps any single call’s worst-case hold on the worker short enough that it doesn’t become the next caller’s problem.

Also reachable as an MCP tool call (generate, Proxy mode — a thin client of this same endpoint, not a second engine) via McpServerApp — see McpServerApp.md.

POST /v1/models/{id}/stream

Same request body shape, but count is optional — omitted means unbounded, streaming until the client disconnects or max.stream.observations is hit. count exceeding max.stream.observations → 400 before any stream starts.

Response: chunked transfer encoding, raw formatted rows, no JSON envelope. Content-Type: text/csv / text/tab-separated-values / text/plain per format.

Response headers (set before body starts):

HeaderMeaning
X-SpiceGrinder-Model-IdEcho of {id}
X-SpiceGrinder-SeedResolved seed
X-SpiceGrinder-Seed-SourceWhere the seed came from
X-SpiceGrinder-Rng-ContinuedWhether this continued an existing resident graph
X-SpiceGrinder-CountOnly present when count was provided in the request

No resultsById entry is minted for a stream — GET /v1/generate/{id}/data does not apply to streamed output.

Not a broadcast mechanism: two concurrent no-seed streams against the same model split one continuing RNG sequence between them, they don’t mirror each other. Use independent explicit seeds for independent, reproducing streams.

DELETE /v1/models/{id}

Response 200: {"modelId": "...", "deleted": true}. 409 if the model is still live on another process. 404 if not found.

GET /v1/generate/{generationId}/data

Query params: offset (default 0), limit (default = server’s DEFAULT_READ_LIMIT).

Response 200:

{"generationId": "...", "modelId": "...", "format": "...", "offset": N, "limit": N,
 "rowCount": N, "totalCount": N, "hasMore": bool, "data": "..."}

404 if the generationId was never produced on this process, or was evicted from the in-memory result cache.

Also reachable as an MCP tool call (read_generation) via McpServerApp — see McpServerApp.md.

GET /v1/components

Lists every component registered and visible under this process’s edition — the same data and the same JSON shape ComponentLibraryApp --json produces (see ComponentLibraryApp.md); both share the same underlying JSON-building code, so a given component’s entry is byte-for-byte identical whether it comes from this endpoint or the CLI tool. Hidden components (ComponentDescriptor.hidden) are never returned, by listing or by component=. The same data is also reachable as an MCP tool call (list_components) via McpServerApp — see McpServerApp.md.

Query params: category (generator|filter, optional) and component (short name, optional) — mirror ComponentLibraryApp’s --category/--component exactly, including the mutual-exclusivity rule.

Response 200 (no params, or category given):

{"edition": "PRO", "totalCount": N, "components": [
  {"name": "Convert", "tier": "Pro", "category": "filter",
   "className": "com.obsvra.spicegrinder.pro.filters.Convert", "description": "...",
   "parameters": [{"name": "class", "type": "String", "required": true, "default": "",
     "aliases": [], "group": "Type", "multiple": false, "minOccurrences": 0,
     "maxOccurrences": 0, "attributes": [], "description": "..."}]}
]}

Response 200 (component given): same shape, components has exactly one entry. 400 if category is neither generator nor filter, or if both category and component are given. 404 if component names a component that doesn’t exist, or is hidden.

GET /v1/allowlists

Reads this process’s configured FlatFile/Database/ServiceCall allowlists straight from UserPreferences — the same boundaries FlatFile.configure()/Database.configure()/ServiceCall themselves enforce, surfaced so a caller can learn what’s permitted without a failed real request first. No query params.

Response 200:

{"flatFilePathAllowlist": ["/data/shared/"], "databaseUrlAllowlist": ["jdbc:h2:"],
 "databaseAllowQuery": false, "serviceUrlAllowlist": ["http://localhost:"]}

An empty array means that capability is disabled entirely on this process (empty allowlist = refuse everything, not “anything goes” — see UserPreferences.requireAllowedFlatFilePath/requireAllowedDatabaseUrl/requireAllowedServiceUrl’s own javadoc), not that it’s unrestricted. The same data is also reachable as an MCP tool call (get_allowlists) via McpServerApp — see McpServerApp.md.

Configuration (ServiceConfig)

Properties file key ↔ system property ↔ default. System property always wins over the properties file.

Properties keySystem propertyDefaultMeaning
max.processesspicegrinder.service.maxProcesses1License-capacity unit: concurrent live sessions
idle.timeout.secondsspicegrinder.service.idleTimeoutSeconds900Session idle-eviction threshold
heartbeat.interval.secondsspicegrinder.service.heartbeatIntervalSeconds30
heartbeat.stale.after.secondsspicegrinder.service.heartbeatStaleAfterSeconds90How long a missed heartbeat is tolerated before a process’s registration is pruned — this is what bounds the post-crash restart-refusal window described in EXIT STATUS above
max.resident.graphsspicegrinder.service.maxResidentGraphs20LRU cap on resident graphs per process
max.stream.observationsspicegrinder.service.maxStreamObservations10,000,000Safety cap on unbounded streams
stream.batch.sizespicegrinder.service.streamBatchSize2048Batched-mode submission size; also monolithic-mode flush size
db.pool.max.connectionsspicegrinder.service.dbPoolMaxConnections20Max pooled ModelServiceStore JDBC connections
streaming.modespicegrinder.service.streamingModebatchedbatched (fair, per-batch worker submission) or monolithic (one job holds the worker for the whole stream)
db.base.path—~/.spicegrinder/service/model-cache
auth.tokenspicegrinder.service.authToken(empty — disabled)If set, every route except /v1/health requires Authorization: Bearer <token>; a bearer token alone doesn’t protect a plain-HTTP connection, so pair it with a TLS-terminating reverse proxy for anything beyond a trusted LAN

Tuning stream.batch.size for your payload shape: the right value tracks bytes per submission, not row count — a real sweep (ServiceLoadTester, 16 sizes × both streaming modes × two payload widths × two observation counts) found the well-amortized zone lands around ~50-150KB per submission regardless of row shape, so a narrow (few-column) row needs proportionally more rows to reach it than a wide (many-column) one. 2048 is tuned for default-shaped rows; if you’re streaming wide/complex objects as the regular case (many columns, nested/long fields), consider lowering stream.batch.size — a large row count at 2048 could mean holding several MB in one submission/flush, working against the low-latency point of streaming in the first place. Conversely, narrow single-column-ish streams can often go higher than 2048 with no downside. There’s no universal right answer here independent of your actual row shape — measure with your own model if it matters to you.

Using custom Pro components

ModelServiceApp’s classpath is fixed at launch, same as any other JVM process — no plugin directory, no hot-reload. A custom generator/filter reaches the Service the same way it reaches the CLI/GUI: be on the classpath when the process starts (-cp for a raw jar, or the app.classpath line in a jpackage install’s generated service.cfg for a packaged one), then reference it by fully-qualified name or a <definitions><custom .../></definitions> alias in the model you register. A classpath change takes effect on the next process start, not mid-run — see the Customization Guide’s “Using custom components with ModelServiceApp” section (included with the Pro download) for the exact commands.

Convert’s class="" attribute is bound by the exact same rule — the named ISerializable business object type has to already be on the Service process’s own classpath at launch, same as a custom generator/filter. There’s no separate allowance for it: naming an arbitrary class by string is the same operation whether it’s a <definitions><custom .../></definitions> alias or Convert’s class="", so it’s gated and resolved the same way, and only takes effect on the next process start. See the Customization Guide (included with the Pro download) for a worked example.

Concurrency model

Single-worker executor per process — one generate/stream job runs at a time. This isn’t just a fairness/simplicity choice: concurrent getNextObservation() calls into generators sharing the same RandomGenerator aren’t safe (see the Customization Guide, included with the Pro download, for the full detail), and every generator built from one model load shares one by default — the single-worker executor is what makes that safe here without touching core RNG code. batched streaming mode bounds how long any one stream can hold the shared worker; monolithic trades that fairness for lower per-call overhead (documented tradeoff, not a bug).

A running stream holds its own direct reference to the acquired IGenerator — cache eviction of the resident-graph map entry doesn’t affect an in-flight stream, only future lookups.