Installing Prism
Read README.md first for what you're getting and what you provide.
If your deployment has a page under customers/, it carries the real names and
hosts for the placeholders below and overrides anything here.
The worked examples here lean Azure — ARO, Azure Container Registry, Azure
Database for PostgreSQL — because that is where the first deployment lives.
Nothing in the chart is Azure-specific: it wants a Kubernetes namespace,
an OCI registry you can pull from, and a Postgres that can create the vector
extension. Where a step genuinely differs by provider it says so and gives the
check that settles it, rather than leaving you to trust an example written for
somebody else's cloud.
1. Before the first install
a. Confirm the vector extension can be created. The agent runner's boot
migration runs CREATE EXTENSION IF NOT EXISTS vector, and crash-loops if the
server refuses. Whether it refuses is a matter of which managed Postgres you
are on, and the two we have measured disagree:
| Provider | What you have to do |
|---|---|
| Azure Database for PostgreSQL flexible server | Add VECTOR to the azure.extensions server parameter (dynamic, no restart). Until you do, the statement is refused. |
| Cloud SQL for PostgreSQL | Nothing — there is no allowlist to add to. CREATE EXTENSION succeeds as it stands, measured on PostgreSQL 16.14, which supplies vector 0.8.1. |
So run the statement yourself before installing, rather than reading a row and assuming. It is idempotent, it is exactly what the runner will do, and it answers the question for any provider — including one not in the table:
psql "host=<postgres-host> dbname=<runner-database> user=<user> sslmode=require" \
-c 'CREATE EXTENSION IF NOT EXISTS vector;'
Run it against the runner database, as the user the chart will connect as
(postgres.external.user) — that role needs the privilege, not just the server.
b. Egress to the Agent Router data plane. Outbound HTTPS/443 from the namespace to your data-plane gateway — the project gateway this install will use. This is the one mandatory external connection; all inference rides it.
The Agent Router management plane is a separate address, and is only
reached if you later take Router spend or directory live (see
Data sources — it needs an admin key). Its URL,
mcps.management.valetServiceUrl, ships empty and is refused at render
once one of those modes is on. Leave it unset while every source is synthetic:
nothing calls it, and nothing asks you for it.
One more outbound connection joins this list if you use local sign-in
(app.identity.mode: local, see Identity and roles):
your SMTP relay, at smtp.host:smtp.port, from the namespace. Sign-in links
arrive by email; with the relay unreachable nobody can ever sign in.
Prove each of these from a pod in the target namespace before you install rather than after — the commands, and how to tell a closed route from a wrong credential, are in Prove the egress.
c. One inference key — you provide it. Issue a project-scoped inference
key through your own key-issuance process (a service-account key on the
project this install serves) and hand it over securely; it goes into the Secret
as tare-inference-key. Prism uses it directly for every user — it does not
mint keys, so no admin platform key is needed and nothing bypasses your key
issuance process.
There are two other Router credentials in the Secret, and they buy different things — one to mint per-user keys instead of this one, one for Router spend. Which Router credential buys what has the table; the short version is that they are not interchangeable and only one of the first two may be set.
A single shared key means no per-Prism-user LLM cost split. That is immaterial for a small audience, and the per-developer PR/Jira/spend analysis Prism shows is unaffected — that comes from the data sources, not from who called the model.
d. Project, key and gateway must match. Each Agent Router project has its own
data-plane gateway, and the default project may have none. A key for the wrong
project is rejected 403 at the gateway ("not authorized for this data plane").
So one Prism deployment serves one project/gateway: set app.projectId,
router.proxyUrl and the inference key so all three name the same project. To
serve two projects, install twice. app.customerId is the tenant your inference
key belongs to.
e. Raise the idle timeout in front of Prism to at least 10 minutes. An answer that makes several tool calls can run for minutes, and Prism sends no keepalive traffic between events, so an ingress, gateway or proxy counting quiet time will cut a working answer mid-stream. The 60-second default several of them ship with is not enough, and the failure presents as "the chat hangs" with a fully green self test. See Limits.
It also bounds a request that works for a while before it answers: running a
context stream's verification question against a large source sends nothing
until it finishes. A proxy with a short timeout drops it and answers with its
own 502, 503 or 504, and Admin → Context streams › Verification then
says on that question that the proxy gave up rather than showing a figure.
On OpenShift the Route's default is 30 seconds, which is shorter than such a
question can take:
oc annotate route <your prism route> --overwrite haproxy.router.openshift.io/timeout=10m
On ingress-nginx the equivalent is two annotations,
nginx.ingress.kubernetes.io/proxy-read-timeout: "600" and
nginx.ingress.kubernetes.io/proxy-send-timeout: "600". Any other proxy or
gateway in the path — a corporate reverse proxy, a load balancer — needs the
same, and the smallest one wins.
Put the annotations where your install owns them, or the next upgrade drops
them. Where this chart renders the Ingress (app.ingress.enabled), its
annotations come from app.ingress.annotations in your values file and are
rewritten on every helm upgrade — so a hand kubectl annotate on that Ingress
is lost at the next one. An OpenShift Route you created yourself is not the
chart's and keeps what you set, but whatever creates it (a template, a GitOps
repo) is where the value belongs.
Prism's own ceiling is 45 seconds per verification question, and it is lower than the timeouts above on purpose: past it Prism stops that question itself and the card carries Prism's own reason rather than a proxy's. Raising a Route timeout does not raise this one.
f. Decide where your org directory comes from. Prism requires one source of directory data — who exists, what they are called in each system, and which team they are on — and if that lives across several systems in your organisation, joining them into one is your work rather than Prism's. In this release Prism derives the identities from the data it mirrors and takes a CSV of email address to GitHub login; the team of each person is typed in, and no pass will ever fill it for you. Nothing fails to install without it — you lose every team-shaped question and nothing else. Worth knowing before you buy, not after you install: One stream must carry your org directory.
g. If the namespace has a ResourceQuota, size it against the chart's limits,
not its requests. A quota on limits.cpu / limits.memory counts every
container's ceiling, used or not. On this release a fresh install reserves 650m
/ 1.5Gi and idles at about 150m of CPU, but 4,500m and 7Gi of limits have to
be permitted before any of it starts — 2,500m / 3Gi of that if you run
Postgres outside the cluster (postgres.bundled=false). Add one Job slot on
top of that for the install itself — the synthetic-seed hook runs on every
install as well as every upgrade, and it starts while the steady pods are
already holding their share of the quota — so 5,000m / 7.5Gi is the working
floor for a fresh default install, or 3,000m / 3.5Gi against an external
Postgres. Leave room above that again for an upgrade, when the ingest Jobs
overlap the steady state too. The per-component table on the Limits page is
generated from the chart you are installing; take the totals from there rather
than from this line if a later release moves them.
A shortfall names none of this. helm reports Error: INSTALLATION FAILED: failed post-install and the pods behind it are Pending or refused at
admission, which reads as a broken cluster rather than a sizing mistake. The
per-component figures, generated from the chart, and how to tell a quota
shortfall from a full node pool: Compute, and the namespace quota you
need.
2. Mirror the images and the chart into your registry
Eleven images, copied from ours to yours with whatever tool your registry prefers,
and then the chart itself. For the images the chart cares about one thing only:
the prefix they end up under, which becomes global.imageRegistry. pgvector
is the bundled Postgres — a third-party image we mirror, scan and digest-pin
into the release so your cluster never pulls from Docker Hub. It runs only
when postgres.bundled=true, and if you are bringing your own Postgres you can
skip it — see If you are not using the bundled
Postgres below, which also says
what to do if you ever turn it on.
Take the list from the chart, not from this page. The chart tarball carries
prism/images.txt — every image in the release, pinned by digest. Pull it first
and mirror from it, so the set you copy is the set this release actually ships
and each image is the exact one we built and scanned:
What you need on the machine you run this from: helm, and one tool that
copies between registries. The examples below use
skopeo; crane and docker also
work, with the caveat about ARM workstations further down. Everything in this
step runs from your workstation or a build agent — nothing runs on the cluster.
And your Cloudsmith credentials, which every command in this step uses:
CLOUDSMITH_USER is your Cloudsmith username and CLOUDSMITH_TOKEN is an API
key or entitlement token from the same account. We issue these to you with the
release; if you do not have them, ask us rather than guessing at an account
name. Set them the way your shell keeps a secret out of its history — read -s
below does that, and a secrets manager is better still.
VERSION=<this release>
DEST=<your registry>/prism # this is what global.imageRegistry gets
CLOUDSMITH_USER=<your cloudsmith username>
read -rsp 'Cloudsmith token: ' CLOUDSMITH_TOKEN; export CLOUDSMITH_TOKEN; echo
helm registry login docker.cloudsmith.io \
-u "$CLOUDSMITH_USER" -p "$CLOUDSMITH_TOKEN"
helm pull oci://docker.cloudsmith.io/tetrate/tetrate-app-dist/prism \
--version "$VERSION" # leaves prism-$VERSION.tgz here
rm -rf "prism-$VERSION" && mkdir "prism-$VERSION"
tar xzf "prism-$VERSION.tgz" -C "prism-$VERSION"
cat "prism-$VERSION/prism/images.txt" # the release, its revision, every image
Keep the .tgz. It is the chart, and you push it into your own registry
below — so this pulls the archive and unpacks it separately, rather than
helm pull --untar, which unpacks and leaves you no archive to push.
Unpack into a directory named for the version, and empty it first. The
images.txt inside is the one the mirror loop reads; a directory left over from
your last upgrade holds the previous release's list, and mirroring from it
puts last release's images under this release's tag with nothing downstream
noticing. rm -rf before mkdir is what makes re-running this safe. The
archive protects itself — its name carries the version — but a plain helm pull
overwrites an existing .tgz without a word, so do not rely on it refusing.
If we cut a build for your environment specifically, its chart comes to you as a
tarball rather than through the registry. Skip the helm pull and start at
rm -rf; everything after it is the same, this step included.
Then mirror what it lists:
while read -r ref; do
case "$ref" in ''|'#'*) continue ;; esac # skip the header (and `# chart:`)
name="${ref%@*}"; name="${name%:*}" # …/prism-app
digest="${ref#*@}" # sha256:…
img="${name##*/}" # prism-app
skopeo copy --all \
--src-creds "$CLOUDSMITH_USER:$CLOUDSMITH_TOKEN" \
"docker://$name@$digest" "docker://$DEST/$img:$VERSION"
done < "prism-$VERSION/prism/images.txt"
If you are not using the bundled Postgres
pgvector is the one image in the release that your cluster may never start.
It runs only when postgres.bundled=true, which is the chart's default — so
this applies if you have set it to false and pointed Prism at a Postgres of
your own. On that install you can leave it out of your registry, and
there is a reason beyond saving a copy: it is a third-party Debian-based image,
so a scan of it reports findings from packages Debian has not fixed — around a
hundred at CRITICAL and HIGH, none of them ours to fix and none of them running
on your cluster. Mirroring it puts all of those in your registry's report for a
container that never starts.
To skip it, filter it out of the loop — one line, so that skipping is something you do rather than something you decide:
while read -r ref; do
case "$ref" in ''|'#'*) continue ;; esac
name="${ref%@*}"; name="${name%:*}"
digest="${ref#*@}"
img="${name##*/}"
case "$img" in pgvector) continue ;; esac # not using the bundled Postgres
skopeo copy --all \
--src-creds "$CLOUDSMITH_USER:$CLOUDSMITH_TOKEN" \
"docker://$name@$digest" "docker://$DEST/$img:$VERSION"
done < "prism-$VERSION/prism/images.txt"
And if you ever turn the bundled Postgres on, mirror that one image first, at the version you are on then — not the version you skipped it at:
grep pgvector "prism-$VERSION/prism/images.txt" | while read -r ref; do
name="${ref%@*}"; name="${name%:*}"
skopeo copy --all \
--src-creds "$CLOUDSMITH_USER:$CLOUDSMITH_TOKEN" \
"docker://$name@${ref#*@}" "docker://$DEST/pgvector:$VERSION"
done
Without it, setting postgres.bundled=true leaves the Postgres pod in
ImagePullBackOff — a clear failure rather than a subtle one, but one whose
cause is easier to remember from here than to work out then.
The Security acceptances section records what a scan of that image reports and why, whether or not you mirror it.
The destination keeps the version tag, because that is what the chart's image
defaults reference: every image tag defaults to the chart's own version, so a
chart pulled at $VERSION asks your registry for $VERSION and there is
nothing to set. The source is a digest, so what lands in your registry cannot
depend on when you ran the loop.
If you tag them something else, set the tags too. The default follows the
chart, not your registry — so a mirror that renames images or drops the version
needs --set <component>.image.tag=... for each one, and a path you mistype is
accepted silently and leaves that component on its old image. crane copy takes the same two
arguments. docker pull + docker tag + docker push reaches the same place
through your workstation, but on an ARM workstation — any Apple Silicon Mac —
it needs docker pull --platform linux/amd64. Ten of the eleven images are
built for amd64 only, so without the flag Docker refuses them with no matching manifest for linux/arm64/v8 in the manifest list entries.
The reason to read that sentence before you need it: pgvector is genuinely
multi-arch and pulls without the flag, and it is the one image that behaves.
So the failure does not look like "this procedure does not work on my machine" —
it looks like one image working and ten being broken. Add --platform linux/amd64 to every docker pull here and all eleven copy correctly. skopeo copy --all and crane copy have no such problem: they copy every architecture
in the manifest list without running anything, which is why they are what the
examples use. If your registry proxies ours on demand there is
nothing to run here at all — set global.imageRegistry to the pull-through
prefix and skip to step 3.
Then push the chart itself
The loop above copies images. The chart is not one of them, and it is not in
images.txt — so unless you push it too, the only copy is ours, and a cluster
or pipeline that cannot reach docker.cloudsmith.io has nothing to install
from. This is the .tgz you kept:
helm registry login <your registry host> -u <user> -p <token>
helm push "prism-$VERSION.tgz" "oci://$DEST"
helm push takes the repository prefix and derives the rest from the chart
itself, so pushing to oci://$DEST publishes $DEST/prism:$VERSION — the same
$DEST the images went to, and the reference you use in steps 5 and 9.
Only your cluster needs the images; helm needs the chart wherever you run
it. If the machine running helm can reach us, you can keep installing
straight from oci://docker.cloudsmith.io/tetrate/tetrate-app-dist/prism and
skip this. Push the chart when your pipeline or cluster is closed to the
internet — which is the usual case for the environments this appliance is
installed into, and the reason this step exists.
Helm 3.7 or later is required to push a chart to an OCI registry; 3.7.2 or
later is recommended. On Azure Container Registry that is the supported route —
az acr helm, the older non-OCI Helm repository support, was retired on 15
September 2025, so a search that turns it up is describing something that no
longer exists. We looked for a version floor or configuration caveat beyond the
Helm client version in Microsoft's own ACR documentation on 8 September 2026 and
found none; a registry that accepts OCI artifacts accepts the chart.
Checking what you mirrored
Image digests survive the copy, and the chart's does not. Worth knowing before you try to verify the wrong one:
- The images can be checked against
images.txt. Copying by digest is byte-preserving, so each mirrored image keeps the digest we pinned.skopeo inspectorcrane digestagainst your registry returns the samesha256:…the file names. - The chart cannot.
helm pushrebuilds the OCI manifest, so your copy has a different digest from ours by design. Nothing is wrong, and there is nothing to compare. What identifies it is the version:helm show chart "oci://$DEST/prism" --version "$VERSION"should report the release you pulled.
Two copies of images.txt, and they differ by one line
Yours — the one inside the chart, which you unpacked above — lists the header and the eleven images. If someone sends you the copy that ships beside the chart in a handover, it carries one line more:
# chart: oci://docker.cloudsmith.io/tetrate/tetrate-app-dist/prism:<version>@sha256:…
Neither is wrong and neither is out of date. A file cannot contain the digest of the archive it is inside, so the copy within the chart is written before the chart is packaged and cannot name it; the copy beside the chart is completed afterwards. That line names our copy, and per the section above it will not match the digest of the one you just pushed.
The mirror loop skips it either way, along with the header, because both are
# comments.
By tag instead, if you would rather not parse a file — the images are the same ones, but you get whatever the tags point at when you run rather than what we pinned, and an image added to a later release is silently missing from your loop:
SRC=docker.cloudsmith.io/tetrate/tetrate-app-dist
for img in prism-app agent-runner management-mcp github-stats-mcp \
jira-stats-mcp github-live-mcp semantic-mcp discovery-mcp synth-seed \
prism-ingest pgvector; do
skopeo copy --all \
--src-creds "$CLOUDSMITH_USER:$CLOUDSMITH_TOKEN" \
"docker://$SRC/$img:$VERSION" "docker://$DEST/$img:$VERSION"
done
Azure Container Registry can do the copy server-side, without pulling anything locally, which is why the Azure form is spelled out separately:
for img in prism-app agent-runner management-mcp github-stats-mcp \
jira-stats-mcp github-live-mcp semantic-mcp discovery-mcp synth-seed \
prism-ingest pgvector; do
az acr import --name "$REGISTRY" \
--source "$SRC/$img:$VERSION" \
--image "prism/$img:$VERSION" \
--username "$CLOUDSMITH_USER" --password "$CLOUDSMITH_TOKEN"
done
az acr import takes --source "$SRC/$img@sha256:…" just as readily, so the
same digests apply to it.
3. Create the secret
The chart reads one Secret. Build it from your own secret store — nothing here should be typed into a values file:
kubectl create secret generic prism-secrets -n <namespace> \
--from-literal=app-jwt-secret="$(openssl rand -base64 32)" \
--from-literal=encryption-key="$(openssl rand -base64 32)" \
--from-literal=mcp-shared-secret="$(openssl rand -hex 32)" \
--from-literal=tare-inference-key="<your project-scoped inference key>" \
--from-literal=postgres-password="<postgres password>" \
--from-literal=runner-database-url="postgresql://<user>:<url-encoded-password>@<host>:5432/<runner-database>?sslmode=require&uselibpqcompat=true"
-
tare-inference-keyis the key from step 1c, and must belong to the same project asapp.projectId/router.proxyUrl. -
The password in
runner-database-urlmust be percent-encoded. An@or/in a generated password otherwise produces a DSN that parses to the wrong host — a confusing failure, because it looks like a network problem. -
uselibpqcompat=trueis required, and is not optional decoration. The agent runner is the one component that is not a libpq client, and without this flag it readssslmode=requireas verify the server certificate chain, where every other component reads the same value as libpq does — encrypt, do not verify. Against a Postgres whose certificate is not chained to a publicly-trusted root (Cloud SQL, or any private-CA instance) the runner then crash-loops before it can log anything, and the only visible symptom is a failing/healthprobe. With the flag,sslmodemeans the same thing to every component. Keep it whateverpostgres.external.sslmodeis set to — the two must agree. -
Use only standard libpq
sslmodevalues.no-verifyis a node-postgres spelling: the runner accepts it and every other component rejects the install withinvalid sslmode value: "no-verify". -
app-jwt-secret,encryption-keyandmcp-shared-secretare generated here and never leave the cluster. They are the app↔runner trust, the runner's at-rest encryption key, and the credential every internal query server checks on every request. -
mcp-shared-secretis new in 0.11.0, and an upgrade without it does not work. The five internal query servers refuse any request that does not present it, so it is not a setting with an off position: unset, they refuse everything. How you find that out depends on who owns the Secret, and the two are not the same failure:Your install Missing value shows up as chart-managed Secret ( secrets.*in your values)helm upgradestops, namingsecrets.mcpSharedSecretand how to generate one. Nothing is changed, and Prism keeps running.existingSecret(the recipe on this page)helm upgradesucceeds, and Prism goes down. The chart renders no Secret underexistingSecret, so there is no value for it to require. Every new pod then fails to start withCreateContainerConfigError, namingmcp-shared-secretas the key it could not find. The five query servers are RollingUpdate, so their old pods keep serving — but the app is a single replica withstrategy: Recreate, which means Kubernetes stops the running one before creating its replacement. There is no UI and no chat until the Secret has the key.So on the
existingSecretpath the ordering is not a tidiness preference — it is the difference between an upgrade and an outage: patch the Secret first, then upgrade.kubectl patch secret prism-secrets -n <namespace> --type merge \-p "{\"stringData\":{\"mcp-shared-secret\":\"$(openssl rand -hex 32)\"}}"If you got the order wrong, the recovery is the same one step: patch the Secret. The app's pod is already gone, so Kubernetes creates its replacement as soon as the key exists; the query servers pick it up on their next roll, which
kubectl rollout restartbrings forward — Rotating a credential lists all six.Why it exists: before 0.11.0 those servers were reachable, unauthenticated, by anything that could open a connection to them inside the cluster. That was documented as network isolation being the fence, and in a Kubernetes namespace with no NetworkPolicy it is not one — a
kubectl port-forwardwas enough to read the people, the spend and the tokens the appliance holds.
4. Fill in your values
Every setting is a chart value rendered into the deployment as an environment
variable, so changing one is a helm upgrade with a new --set — no image
rebuild, and no reinstall.
Write them in your own file, kept outside the chart. Somewhere you keep
your own configuration — beside your other Kubernetes manifests, in your
configuration repository — and pass it with -f on every command on this page.
Something like prism-values.yaml, in a directory of yours.
Do not edit the chart's own
values.yaml. If you untar the chart, that file sits in front of you and is the obvious thing to fill in. It is the wrong one, and it fails in a way that gives you no warning: the next release is a new tarball,values.yamlin it is ours again, and every value you set reverts on the upgrade. Nothing errors — the install comes up on our defaults for anything you edited there.This is not hypothetical. It cost the first customer a release: a setting they had been told to apply was written into a file they did not install with, and every question failed until it was applied by hand a second time.
Your file is also the one you can read back, diff, and put under review. The chart's is a file you get a new copy of every time.
Three Agent Router URLs ship empty, and helm upgrade refuses to render
until you set them. router.platformUrl and router.proxyUrl are required on
every install. mcps.management.valetServiceUrl is required only once
sources.spend is indexed or live — so an
all-synthetic pilot never needs it, and indexed does count, which is the mode
you will most likely want. That is deliberate: they
are addresses on your network, we cannot guess them, and a plausible-looking
default is worse than no default. An install pointed at the wrong Router comes
up perfectly healthy and answers nothing.
helm names every missing required value at once. Up to 0.13.0 it stopped
at the first one, so the required set was discovered one failed render at a
time: seven attempts on the bundled datapond, and eight on an external one,
each of them a real helm install or helm upgrade. Since 0.13.1 a render
with none of them set fails once and lists them all, numbered, each with
what it is for — so they can be filled in in a single edit of your values file.
The list is complete for your values file, not in the abstract:
- With
existingSecretset the chart renders no Secret of its own, so it asks for none of thesecrets.*keys — the list is then the two Router URLs alone. Those keys are still required; they are in the Secret you created in step 3, where a missing one is aCreateContainerConfigErroron the pod rather than a refused render. postgres.external.hostjoins the list only onpostgres.bundled=false.- A value some other setting turns on is checked where it is used, not in this
pass, so it can still stop a later render one at a time:
mcps.management.valetServiceUrloncesources.spendisindexedorlive,ingest.github.orgsoncesources.githubislive,ingest.jira.urloncesources.jiraislive,app.ingress.hostwith the ingress enabled, and — onapp.identity.mode: local—app.auth.superAdmins,smtp.host,smtp.fromAddressandapp.auth.baseUrl, which are still met one render each. Turning a source or a sign-in mode on is a separate step on purpose, and it has its own checks.
On a chart-managed Secret, from an empty values file, the render asks for
secrets.inferenceKey or secrets.tarePlatformApiKey,
secrets.appJwtSecret, secrets.encryptionKey, secrets.mcpSharedSecret,
secrets.postgresPassword, router.platformUrl and router.proxyUrl — plus
postgres.external.host when the datapond is external.
secrets.postgresPassword is required in both datapond modes: on
postgres.bundled=false it is the password for postgres.external.user, not a
bundled-only value.
The fields you must set:
| Field | Set to |
|---|---|
global.imageRegistry | where you mirrored the images, including the repository prefix |
router.platformUrl | your tenant's management-plane URL, from your Router console. A URL only — no key needed with bring-your-own inference — but the URL itself is required in both key modes |
router.proxyUrl | the data-plane gateway for the project this install serves. Required, and it does not default to router.platformUrl |
router.defaultModel | a model your gateway actually serves — see model.md |
app.customerId | your Router tenant — the one your inference key belongs to |
app.projectId | the Router project this install serves; must match the gateway and key (step 1d). Required — it defaults to default, which is correct only if your Router serves a data plane on the default project, and setting it empty is refused |
postgres.external.* | host, port, user, the two database names, and sslmode — a standard libpq value (require by default: encrypted, server certificate not verified). verify-ca/verify-full need a CA bundle the pods can read. |
router.keyMode | only when you set existingSecret — byok if that Secret holds tare-inference-key, mint if it holds tare-platform-api-key |
app.projectId is load-bearing: it drives the app's project and what the
runner will accept. A mismatch is the 403 in step 1d.
It may not be left empty either. app.projectId ships as default, so
leaving the key alone is a choice — the right one if your Router serves a data
plane on the default project, and the wrong one on a gateway that serves
named projects, where default is the single value guaranteed to fail. Setting
it to "" used to be quietly turned back into default on both sides (the
app's project and the runner's allowed list), so the two agreed perfectly on a
value nobody had typed: a clean render, healthy pods, and a 403 on every
question that reads as a credential fault. helm upgrade now refuses an empty
value, naming it. A real project slug — or default, deliberately — is what it
wants.
Neither Router URL may be left empty, and the install refuses rather than
letting you find that out later. helm upgrade fails at render naming the
value, because the runtime symptom points nowhere near the cause: an empty
router.platformUrl crash-loops the agent runner on TARE_PLATFORM_URL is not set — a variable name that appears in no values file — and an empty
router.proxyUrl starts cleanly and then fails every question against the
address /v1, while the self test reports the inference endpoint as not
configured rather than as broken.
router.keyMode changes nothing about how the install runs — the runner reads
the credential itself. It is there so the self test knows whether
router.platformUrl is an address this install ever calls: only minting a
per-user key does, so on a bring-your-own-key install a red against the
management API is noise. The chart works this out for itself from
secrets.inferenceKey / secrets.tarePlatformApiKey; with existingSecret it
can see neither, and the self test says so rather than guessing.
postgres.bundled defaults to running an in-cluster instance, which is
pilot-grade only — and it will not start under OpenShift's restricted-v2
SCC, which hands the pod a random UID that cannot chown the data directory; the
same SCC refuses the pinned UID of the init container that gates agent-runner
and synth-seed on it, so those two are refused admission as well. Set
postgres.bundled: false and point at a managed instance.
5. Install
One release per project/gateway. app.projectId and router.proxyUrl must be a
matching pair — see step 1d.
helm upgrade --install prism \
"oci://$DEST/prism" --version "$VERSION" \
-n <namespace> \
-f <your values file> \
--set existingSecret=prism-secrets
$DEST is where you pushed the chart in step 2.
If you did not push it — because the machine running helm can reach us — use
oci://docker.cloudsmith.io/tetrate/tetrate-app-dist/prism instead. The images
come from global.imageRegistry in your values file either way; the chart
reference and the image registry are separate settings, and only one of them is
this line.
A second concurrent deployment can share a namespace and a Secret, but not
the two databases: the runner is a single writer on its state database, and both
seed jobs would fight over the data. A second concurrent release needs its own
pair of databases (postgres.external.database / .runnerDatabase).
6. Verify
kubectl get pods -n <namespace>
# 5 running (app, agent-runner, 3 MCPs) + synth-seed job Completed
kubectl logs -n <namespace> job/prism-synth-seed | tail -5 # seeded rows
kubectl port-forward -n <namespace> svc/prism-app 8500:80
# then http://localhost:8500
Ask it something — "who were the top contributors last month?". An answer means the whole path is up: app → runner → data-plane gateway (inference with your key) → MCP → database. The numbers it quotes are synthetic until the sources are flipped.
Then run the self test at /selftest. It checks each hop
separately and tells you which one is at fault — including whether a failure is
Prism or the Agent Router upstream of it. Run it once on a healthy install so
you know what good looks like.
7. Data sources
The admin page you connect and check these on is Admin → Context streams
(/admin/sources, super admin only) — one row per context stream, with the
organizational one pinned at the top. It is what a default install serves from
0.10.0 on: sources.registry.enabled now defaults to true.
Setting it to false serves the older read-only Data sources page at the
same address instead, and renders none of the registry's own configuration —
not even the variables. That is a supported state and a single-upgrade
rollback, but it is no longer the default, and on an install that has already
run with the registry on it leaves the sources you connected in place and
unread rather than deleting them.
Each source flips independently, as its access lands:
| Source | Needs | Then |
|---|---|---|
| GitHub | network access + a read-only service account | --set sources.github=live --set ingest.github.orgs=<orgs> |
| Jira | network access + a service account | --set sources.jira=live --set ingest.jira.url=<your Jira> |
| Router spend | an admin Router management key (insights reads need admin), and the management URL | --set secrets.valetApiKey=<admin> --set mcps.management.valetServiceUrl=<your Router management URL> --set sources.spend=indexed |
Set the credential and target first, and upgrade once without flipping. The
chart probes an upstream you are preparing and reports on the
self test whether it is ready — upstream ready to go live, or
amber with the fault — so the destructive step below happens against a
credential you have already seen work. Flip on the second upgrade.
For Router spend the target is the URL as well as the key: set
mcps.management.valetServiceUrl in this preparatory upgrade, not only in the
flip. The chart does not require it until a source is actually indexed or live,
so a preparatory upgrade without it renders happily — and then the probe has no
address to call and reports amber for as long as you leave it, whatever the
credential is worth.
Take a source live
has the sequence, and the reason the order is not negotiable is the paragraph
below.
sources.spend takes indexed or live, and indexed is the one to want.
Both read the same Agent Router. live asks it inside the chat turn, and
because the Router's aggregated usage rows carry no owner, attributing cost to
named people costs one call per person — a table of a dozen engineers is a
dozen synchronous round trips, and it gets slower the larger the organisation
is. indexed sweeps the same request logs into the datapond nightly
(ingest.spend.schedule), so the same question is a query: fast, per-key, and
still answerable when the Router is briefly unreachable. What you trade is
currency — the answers say how fresh the index is and whether it reaches the
period asked about, rather than leaving you to wonder.
The first live ingest for a source truncates that source's synthetic rows before writing real ones. If the ingest then writes nothing, you are left with an empty source rather than the synthetic one you had — which from the chat is indistinguishable from Prism being broken. The self test calls this state out explicitly rather than showing a zero.
The first backfill is long, and helm does not wait for it
helm upgrade returns as soon as the backfill has started. That is
deliberate: the first run walks ingest.backfillDays (default 90) of history
for every org or project you named, which runs to over an hour —
one GitHub org over 90 days measured at 77 minutes (8,965 pull requests and
14,386 issues). It walks issues as well as pull requests, so the
time follows both, and a larger org takes proportionally longer. Expect it to
still be running long after the command has come back clean, and do not read a
prompt as "the data is in".
In ingest.engine.mode: authoritative add roughly 9–18 minutes per GitHub
organisation at the default settings: issues reach back at least 365 days
there, and each day past the pull-request window costs one extra issue-search
request per 100 issues updated that day — one or two at the density the
measurement above saw. The figure is that arithmetic applied to the extra
days, not a second measurement, and there is nothing to add once
ingest.backfillDays is 365 or more — 365 days is a floor on the issue walk,
not a cap, so both lanes then walk the same window. See
Issues reach back a year.
Watch it finish:
kubectl get jobs -n <namespace> -l app.kubernetes.io/component=github-ingest
kubectl logs -n <namespace> -l app.kubernetes.io/component=github-ingest -f
The log prints the window it is walking and a line per day bucket, so progress is visible rather than inferred. Admin → Context streams shows per-source row counts filling in, and the self test reports the source as populated once it has.
Two things to know while you wait:
- Re-running the flip command is safe and does nothing. The ingest Job is
named after its own configuration, so an identical
helm upgradeleaves the running backfill alone. Changing the configuration — adding an org, changingingest.backfillDays— replaces it with a new run. - You do not need to babysit it. The CronJob picks the source up on its normal schedule whatever happens to this run, and a GitHub backfill that dies part-way resumes from the last completed day rather than starting over. A source still showing zero rows hours later is a credential or egress problem, not a slow backfill — the self test's upstream check says which.
8. Identity and roles
app.identity.mode names what fronts the app for identity:
| Mode | What it means |
|---|---|
static (default) | no identity front door. Everyone using the app is the one env-configured operator identity. Demo/pilot only — wire a front door before multi-user use. |
easyauth | something in front of the app asserts the user via X-MS-CLIENT-PRINCIPAL-* headers. Azure Container Apps' built-in authentication does this natively; so does any OIDC proxy you configure to emit them, which is how an identity provider like Entra ID is put in front of Prism on Kubernetes or OpenShift — see Sign-in with Entra ID. The mode is a header contract, not an Azure feature; the name is historical. |
iap | Google Cloud IAP asserts the user via X-Goog-Authenticated-User-* headers. |
local | the app is its own front door: allowlisted emails sign in with a single-use emailed link. The multi-user path when no IdP integration exists yet — see Local sign-in below. |
The modes are mutually exclusive; the app refuses to start with more than one configured.
Roles. Everyone the front door admits is a user: they can chat, save
prompts, and see their own history. The emails listed in app.auth.superAdmins
additionally hold super admin, which is what two surfaces require under
easyauth/iap/local:
/admin— the admin pages and their APIs. It is a hub listing the admin surfaces this installation has: Context streams (what this installation is connected to: which mode each source runs in and how fresh its data is), Answer feedback (what readers have reported about an answer) and App users (who holds which role, and underlocalwho may sign in at all). An entry that does not apply to your install is not listed, and its page is not served;/selftest— the diagnostic report (it names image refs, internal URLs and this install's configuration).
Matching is by the email the front door asserts, case-insensitively. So for
any easyauth/iap install, set at least one:
Left empty, nobody can open those pages — the app fails closed and helm
prints a warning at install time. Adding or removing an administrator is a
helm upgrade with the new list; no rebuild, no reinstall. Under static
mode the list is ignored: with no front door there is only the one operator
identity, and it reaches everything.
One constraint worth knowing under Easy Auth: the asserted email is best-effort. An account whose sign-in carries no usable email claim cannot be matched to the list and stays a plain user — the fix is an email claim at the IdP, not a Prism setting.
Operators without a sign-in still have a diagnostic path: see the CLI in self-test.md.
Local sign-in (mode: local)
With no IdP to put in front of the app, Prism can be the front door itself:
only emails on an in-app allowlist can sign in, each sign-in mails a link that
works once and expires in minutes (app.auth.magicLinkMinutes), and the
session lives in a cookie for app.auth.sessionDays days. There are no
passwords anywhere in this: owning the mailbox is the credential.
Local mode requires three things, and the install refuses to proceed —
helm fails at render and the app refuses to start — while any is missing:
| Value | What it is |
|---|---|
app.auth.superAdmins | at least one email. These are the bootstrap administrators: the only logins that work until they sign in and add others on /admin → App users. Not special-cased — seeded as ordinary allowlist rows. |
app.auth.baseUrl | the external URL your users browse to (the ingress/route hostname). The emailed link points at it; the pod cannot infer it. |
smtp.host (+ smtp.fromAddress) | your SMTP relay and the sender address it accepts. Port, TLS mode (smtp.tls: starttls/implicit/none — any other value fails at render) and optional AUTH username are beside it; an AUTH password goes into the Secret as smtp-password. TLS certificates are verified by default; smtp.tlsVerify: false exists only for a relay presenting a private-CA certificate the pod cannot verify (for a relay with no TLS at all, tls: none is the honest setting). |
After the first install, run the self test: its smtp relay line asks the
relay — without sending mail — whether it would deliver to each bootstrap
address. Everything else can be green and the install is still dead on arrival
if the first magic link cannot reach the one person who can let anyone else in.
What an upgrade does to the admin list. Seeding is additive, never reconciling: every boot creates a super-admin row for any listed email that lacks one, and touches nothing else. Admins appointed in-app survive every upgrade; an admin demoted in-app stays demoted even while still listed; shortening the list revokes nobody. Two consequences worth stating plainly:
- revoking an administrator is done in-app (demote or remove on
/admin→ App users), and — for bootstrap admins — by also removing them fromapp.auth.superAdmins, or the next restart re-adds them; - locked out (the admins all left, nobody can sign in) is recovered by
adding an email to
app.auth.superAdminsand runninghelm upgrade— the break-glass path needs cluster access, not a Prism login. Because seeding never touches an existing row, the address must be one the allowlist has never seen: a listed address that already has a row keeps its current role, so re-listing a demoted admin re-promotes nobody (the skipped seed is logged asseed_skipped_existing).
One rule is enforced on top: the last super admin can never be removed or demoted — the API refuses with "cannot remove the last super admin". Hand an install over by promoting the new owner first, then demoting yourself.
Removing a user ends their sessions immediately, and any sign-in link already in their inbox dies with them. Sign-out is the header's "sign out" button.
9. Upgrading to a new release
Upgrading is the install command again with a new version. There is no separate upgrade path, and nothing to migrate by hand.
Read the release notes first. That is where a new required value, a changed default or a new secret key is called out. A release that needs something you have not set refuses to render rather than installing half of itself, and the error names the value it wants.
Mirror the new images and push the new chart, the same loop as step 2 with the new VERSION — including the
--untardir "prism-$VERSION", which is what stops the pull landing on the
previous release's directory and the loop mirroring the previous release's
digests under this release's tag. All
eleven images ship under one immutable tag, so one version number moves the whole
set. The chart is under that tag too on a standard release; if we hand you a
build cut for your environment specifically, its chart comes to you as a
tarball instead and is not in the registry — install from the file we send, and
mirror the images exactly as above.
Then run the command you already ran:
helm upgrade --install prism \
"oci://$DEST/prism" --version "$VERSION" \
-n <namespace> \
-f <your values file> \
--set existingSecret=prism-secrets
Same chart reference as the install: your registry if you pushed the chart there, ours if you did not.
Your values file and your Secret carry over untouched. Keep passing both every time: Helm replaces the release's configuration with what you give it, so a flag you drop is a flag you have unset.
The image tags move with --version and nothing else. All ten default to
the chart's own version, so the command above is the whole upgrade — there is
no tag to bump in your values file and no --set to add. If you are carrying
image tags in your values file today, delete them: a pinned tag still wins,
which means a values file left over from an older release pins that release's
images and the upgrade quietly leaves those components behind. Prism versions
are visible on the admin pages; the self test is the check.
Untouched only if the file is yours. This is the upgrade where editing the
chart's own values.yaml catches up with you: the new tarball brings a new
copy, your edits are not in it, and the release comes back up on our defaults
with nothing reported. A values file kept outside the chart (step 4) is the
thing that makes this step as safe as it reads. Since 0.8.0 the three Router
URLs ship empty and refuse to render, so the most damaging half of that failure
now stops the upgrade instead of completing it.
Finish with the self test, and compare it against the run you did on a healthy install.
What the upgrade does on its own
- Schema changes apply themselves. Each component creates and alters its own tables idempotently when it connects, and the agent runner runs its migration at boot. There is no migration job to run and no order to observe.
- Synthetic sources are regenerated. The synth-seed Job re-runs on every
upgrade and rebuilds every source still set to
synthetic. Sources set toliveare left alone. - Live data is not re-ingested. Watermarks survive the upgrade, so each
source resumes where it left off. Adding an org or changing
ingest.backfillDaysstarts a fresh backfill instead, which runs for tens of minutes and does not hold up the command. - The upstream check re-runs, so the self test's verdict on each live source is current as soon as the upgrade lands.
- Super admins are seeded, never reconciled. Any email in
app.auth.superAdminswith no row gets one, and nothing else is touched. An admin demoted in-app stays demoted, and shortening the list revokes nobody.
Rolling back
helm rollback prism restores the previous release's images and configuration.
Data written since the upgrade stays where it is. Schema changes so far have
been additive, so an older image ignores columns it does not recognise rather
than failing on them. Read the release notes for the version you are leaving
before you rely on that: a release that drops or renames something is the case
where it stops being true.