Skip to main content

API Integration Guide

How external systems call PlantPulse AI's MCP and API directly.

Do not poke at addresses that no longer exist

The old standalone services MCP Server(:50000) · Ontology(:8888) have been archived, and :7114, which used to open LightRAG directly, no longer exists either. Tools are gathered at a single address, the MCP Gateway → MCP Tool Map


MCP clients connect through the single gateway address​

External MCP clients (other agents · desktop apps, etc.) connect through one address, https://<호스트>/mcp/gateway/ (Streamable HTTP). The tools of the platform's unified MCP · Time Series Insight · RAG · Document Understanding · Vision · Engineering Calculation · Digital Thread · external servers added by the operator are all gathered at this address.

To doWhere
Get a keyIssue a key in AI Admin Console › MCP Gateway › Client API Keys — the key (ppgw_…) is shown only once, right after it is issued. Give the key a scope (equipment · site · project · customer PO · supplier) and a grade — a key without a scope can call only external server tools
Choose which tools it seesPermissions in the same menu — a new key starts with nothing allowed. For each server, allow «All tools» or only some tools
CallX-API-Key: ppgw_… or Authorization: Bearer ppgw_…
View callsMonitoring › Calls & Metrics — calls are recorded per key
  • A missing or wrong key gets 401; a client that fails more than 20 times in one minute gets 429 for the rest of that minute.
  • This path forwards only the gateway's MCP (/mcp/). The gateway's management REST (/api/v1) is not exposed outside.
  • /mcp/gateway without the trailing slash is redirected to /mcp/gateway/ (308).
  • Screens · permissions · key revocation → AI Admin Console — MCP Gateway

Other endpoints​

ServiceURLProtocolAuth
MCP Gatewayhttps://<호스트>/mcp/gateway/MCP (Streamable HTTP)gateway key ppgw_… — above
Copilot Public APIhttps://<호스트>/api/v1/…RESTX-API-Key → below
LiteLLM (Models)http://<server>:4000OpenAI compatiblemaster key
Unified MCP (Platform)<platform>/api/v5/mcpMCP (JSON-RPC 2.0)api_key — Authorization: Bearer or X-API-Key

Direct service access — for operators · system integration​

The in-house MCP servers are also open directly, without going through the gateway. However, their authentication is a single installation-wide inbound key (PI_INBOUND_API_KEY), so whoever holds that key has full rights, and there are no per-caller tool permissions and no call log. Use it only for system integration such as operator scripts · platform CEP, and give customers' MCP clients the gateway.

ServiceDirect addressAuth
Time Series Insighthttp://<server>:8970/mcp/ · https://<호스트>/mcp/timeseries/inbound key
RAG Gatewayhttp://<server>:7115/mcp/ · https://<호스트>/mcp/rag/inbound key
Document Understandinghttps://<호스트>/mcp/parser/ (host port 7120 is not published)inbound key
Visionhttps://<호스트>/mcp/vision/ (host port 7130 is not published)inbound key

The only way to reach Digital Thread · Engineering Calculation · the platform's unified MCP at a single address is the gateway.

Also available as paths on the default host​

Without opening separate subdomains or ports, the proxy forwards the following via paths on the default host (the same address as chat).

PathForwards to
https://<호스트>/mcp/gateway/MCP of the MCP Gateway — external MCP clients use this
https://<호스트>/llm/LiteLLM — base_url in the OpenAI SDK is https://<호스트>/llm/v1
https://<호스트>/mcp/rag/MCP of the RAG gateway
https://<호스트>/mcp/timeseries/MCP of Time Series Insight
https://<호스트>/mcp/parser/MCP of Document Understanding
https://<호스트>/mcp/vision/MCP of Vision
These paths open nothing by the proxy itself

Authentication is done by the back end — /mcp/gateway/ needs the gateway key ppgw_…, the direct MCP mesh needs X-API-Key: <PI_INBOUND_API_KEY>, and /llm/ returns 401 without the LiteLLM master key. Since the default host is typically an address with external DNS attached, be sure to check after installation that no key is left exposed via CHANGE_ME_… → Credentials Management

The LiteLLM admin screen (/llm/ui) does not open via this path — open it via :4000.

Inbound key on the back end​

Time Series Insight · RAG · Document Understanding · Vision · Digital Thread all use the same inbound key (PI_INBOUND_API_KEY in the installation env) → Credentials Management. Send it as X-API-Key: <key> or Authorization: Bearer <key>.

  • Only /ping · /health open without a key. /metrics also requires a key.

  • Missing or wrong key results in 401 (RAG uses missing or invalid API key).

  • 🔴 If the key is empty, the service will not start (fail-closed). The installation check (bin/lint-env.sh) stops first with [fail] PI_INBOUND_API_KEY is empty and PI_INBOUND_AUTH_DISABLED is not true, and the service log leaves FATAL: … is empty — refusing to start …. There is no "silently open back end" with an empty key.

  • To deliberately turn off auth (e.g., an isolated test environment), set PI_INBOUND_AUTH_DISABLED=true in the installation env (default false). compose passes this value under a per-service name.

    ServiceName the container reads
    RAGRAG_INBOUND_AUTH_DISABLED
    Time Series InsightTS_INBOUND_AUTH_DISABLED
    Digital ThreadDT_INBOUND_AUTH_DISABLED
    Document Understanding · VisionINBOUND_AUTH_DISABLED

    If a key is set, the key wins (lint-env reports this via [note] … is ignored — PI_INBOUND_API_KEY is set and the key wins). Values outside true · false are [fail]. A service that started with auth off records this as such in /health (Digital Thread auth.inbound=disabled, Time Series Insight inbound_auth: "disabled") — running Run Check on the API Keys screen of the admin console catches it as open → AI Admin Console — API Keys

Document Understanding·Vision do not publish host ports (7120 · 7130) — from outside they are reachable only via the /mcp/parser/ · /mcp/vision/ paths above. Digital Thread (7140) also does not open a host port.

MCP paths include the trailing slash

It's /mcp/, not /mcp. Omitting the slash causes a redirect or a 404.


Calling the MCP protocol​

All five servers follow the MCP (Model Context Protocol) standard. The plant data·ontology tools are on the platform's unified MCP (/api/v5/mcp), waveform analysis is on Time Series Insight, and documents·images are on the remaining three.

Prefixes on tool names are a chat-only thing

When calling a server directly, use the unprefixed name (analyze_asset). Prefixes like insight_ are attached by chat's tool registrar, so the name you see on the chat screen differs from the name used here.

Basic MCP structure​

MCP is HTTP POST communication based on JSON-RPC 2.0. The unified MCP requires api_key authentication (Authorization: Bearer <token>).

POST /api/v5/mcp HTTP/1.1
Content-Type: application/json
Authorization: Bearer <platform.api.token>

{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "<tool-name>",
"arguments": {
"<param1>": "<value1>",
"<param2>": "<value2>"
}
}
}

Retrieving the tool list​

curl -X POST <platform>/api/v5/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <platform.api.token>" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}'

Tool call example​

Almost always starts with search_domains

People speak in names; tools take IDs. The default order is for search_domains to do a partial match across 14 domains to pin down "domain + ID," then pass that ID to the detail tool.

① Look up by name to get the ID​

curl -X POST <platform>/api/v5/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <platform.api.token>" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "search_domains",
"arguments": {
"query": "압축기",
"domain": "asset",
"limit": 20
}
}
}'

If you know domain, specify it for a precise search; if not, omitting it splits results by domain.

② Expand the asset hierarchy​

curl -X POST <platform>/api/v5/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <platform.api.token>" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_asset_tree",
"arguments": { "asset_id": "<①에서 얻은 ID>" }
}
}'
A missing tags key does not mean "no tags"

By default only the count (tag_count) comes with the tag. Pass include_tags: true only when you actually need the name·tag_id — the response grows several times larger. The source of truth for tag presence is tag_count, and only tag_count: 0 means genuinely no tags.

③ Read tag current values at once​

curl -X POST <platform>/api/v5/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <platform.api.token>" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_tag_values",
"arguments": { "tag_ids": ["<tag_id>", "<tag_id>"] }
}
}'

tag_ids is an array, and up to 100 at once can be included. In the response, found: false means an unregistered tag, and error means a lookup failure (worth retrying). Historical period lookup is get_tag_history.


Unified MCP Authentication​

The unified MCP requires api_key authentication. The value chat uses is Copilot's platform.api.url · platform.api.token (environment variables CO_PLATFORM_API_URL · CO_PLATFORM_API_TOKEN — compose moves PP_API_URL · PP_API_TOKEN from the host env into these). The prefix CH_ used through 2026.09 changed to CO_ in 2026.10, with no fallback.

⚠ The old names mcp.api.url · mcp.api.token no longer exist. Old config rows are migrated to the new names at startup, but if you set the environment variable under the old name, nothing happens.

Auth header​

curl -X POST <platform>/api/v5/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <platform.api.token>" \
-d '{ ... }'

Calling the ontology (knowledge graph)​

Ontology tools have been absorbed into the unified MCP. 🔴 There is no tool named ontology_* — that was the name from the old standalone service (:8888) days. There are now three.

ToolWhat it answers
get_ontologyConfiguration overview of one site — zone/line/equipment structure + tag count per equipment
query_ontologyDirectional relationship search — "what does X affect," upstream·downstream
query_sparqlMulti-condition cross query (SELECT / ASK only)

The names are similar but return different axes. For pure hierarchy structure use get_asset_tree, for site configuration overview use get_ontology, and for relationship propagation use query_ontology.

Site configuration overview​

curl -X POST <platform>/api/v5/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <platform.api.token>" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_ontology",
"arguments": { "site_id": "<site_id>" }
}
}'

site_id is required — resolve it first via domain: "site" on search_domains.

Only total_tag_count is the source of truth for total tag count

Do not sum the per-node tag_count — unexpanded branches will be missing. total_tag_count_unknown: true means "could not count," so do not estimate.

Relationship search — "what does this affect"​

curl -X POST <platform>/api/v5/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <platform.api.token>" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "query_ontology",
"arguments": {
"entity_id": "<설비 ID>",
"direction": "DOWN",
"depth": 2
}
}
}'
ArgumentValueMeaning
entity_id(required)The entity to start the search from
relation_typeFEEDS · DEPENDS_ON · PRODUCESAll if omitted
directionUP · DOWN · BOTHDefault DOWN (downstream impact)
depth1 ~ 5Default 2

The response is root + nodes[] + edges[] + truncated. If truncated: true, it was truncated by depth or breadth, so narrow the scope and ask again.


Calling the Time Series Insight MCP​

Equipment anomaly detection​

curl -X POST http://127.0.0.1:8970/mcp/ \
-H "Content-Type: application/json" \
-H "X-API-Key: <PI_INBOUND_API_KEY>" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "detect_asset_anomaly",
"arguments": {
"asset_id": "ASSET_DJ_M_01_1",
"lookback_minutes": 1440,
"top_k": 5
}
}
}'

If threshold is omitted, the default is 5.0. Sensor anomaly scores are not 0~1 but a calibrated score (robust z relative to the baseline period), so setting 0 counts everything as anomalous even for a small rise above the baseline median → AI Models

Equipment sensor prediction​

curl -X POST http://127.0.0.1:8970/mcp/ \
-H "Content-Type: application/json" \
-H "X-API-Key: <PI_INBOUND_API_KEY>" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "forecast_asset",
"arguments": {
"asset_id": "ASSET_DJ_M_01_1",
"prediction_length": 96,
"enable_bounds": true
}
}
}'

Equipment health score​

curl -X POST http://127.0.0.1:8970/mcp/ \
-H "Content-Type: application/json" \
-H "X-API-Key: <PI_INBOUND_API_KEY>" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_asset_health",
"arguments": {
"asset_id": "ASSET_DJ_M_01_1"
}
}
}'

Calling RAG (LightRAG)​

The RAG engine is LightRAG 1.5.7 (the image we've wrapped since 2026.10, pi/plantpulse-lightrag), but 🔴 it is not called directly. LightRAG lives only inside the container, and the only externally reachable spot is the rag-api(7115) gateway alone.

Document upload·listing·deletion are done in the admin console (the old LightRAG WebUI has been absorbed into the console). Search is called via the gateway's REST, authenticated with X-API-Key.

info

Document search in copilot chat calls this gateway's MCP tool rag_search through the MCP Gateway (the copilot has no built-in document search). REST /api/v1/query and MCP rag_search return the same envelope — neither attaches to LightRAG directly. The response shape below is not LightRAG's but the envelope defined by the gateway.

curl -X POST http://127.0.0.1:7115/api/v1/query \
-H "Content-Type: application/json" \
-H "X-API-Key: <PI_INBOUND_API_KEY>" \
-d '{
"query": "펌프 정비 절차",
"mode": "hybrid"
}'
ArgumentValue
querySearch query — 3 characters or more (shorter returns 400)
modehybrid (default) · naive · local · global — any other value returns 422
ResponseMeaning
{"retrieved": true, "answer": …, "sources": [{"file_name": …, "reference_id": …}]}Found. reference_id is the n in the source marker [n] in the answer body (absent for sources the engine did not number)
{"retrieved": false, "code": …, "reason": …, "sources": []}Not found. The answer key itself is absent
HTTP 502Engine-side failure
retrieved: false is not an error but "nothing found"

HTTP is 200. If code is NO_CONTEXT, there's no relevant content in the indexed documents; if EMPTY_ANSWER, the engine returned empty-handed. Calling again returns the same answer — do not display a response without answer as though it were a result.


Drawing comparison — differences between two revisions​

Usable on installations with drawing collection (DRAWING_INGEST_ENABLED=true) turned on → Configuring Environment Variables. There are two entry points, and the comparison itself is done by Document Understanding. rag-api pulls the originals of the two indexed revisions and hands them to Document Understanding, returning its result as-is. Both require the inbound key.

Entry pointWhen
rag-api GET /api/v1/drawings/{asset_id}/{doc_name}/diffAlready indexed drawings — finds the prior revision and compares
Document Understanding POST /api/v1/drawings/diffCompares two files on hand directly (regardless of indexing)

Comparing against the prior revision of an indexed drawing (rag-api)​

curl -G "http://127.0.0.1:7115/api/v1/drawings/<asset_id>/<doc_name>/diff" \
-H "X-API-Key: <PI_INBOUND_API_KEY>" \
--data-urlencode "lang=ko"
ArgumentMeaning
againstThe document name of the prior revision to compare against. If blank, picks the one whose revision marker is immediately prior among the same drawing number (A < B < … < Z < AA, 1 < 2 < 10)
against_assetThe equipment ID if the prior revision is attached to a different equipment (default: same equipment)
langLanguage for the summary line, ko · en. If blank, installation language (PP_LANG). Numbers are as written on the drawing regardless of language
StatusMeaning
404Either side is not indexed, the original is not archived, or there's no prior revision to pick
422The comparator could not read the file
502Could not reach the comparator or object storage

The same router has page previews (…/page/{n} — PNG for PDF, SVG for DXF) and revision status (…/revision-status — current · outdated · change_open · unknown).

Comparing two files directly (Document Understanding)​

Since Document Understanding does not publish a host port, call it externally via the parser.* host.

curl -X POST "https://parser.<domain>/api/v1/drawings/diff" \
-H "X-API-Key: <PI_INBOUND_API_KEY>" \
-F "old=@A-100_rev-A.dxf" -F "new=@A-100_rev-B.dxf" -F "lang=ko"
  • Files may be .dxf or vector .pdf with a text layer, up to 50 MB each. DWG is not accepted.
  • The default for lang is en (differs from the rag-api entry point).
  • Rejections come with error_kind: input, for extension 415 · size 413 · unreadable 400.

Shape of the result​

KeyContains
titleChanged title block fields
dimensionsadded · removed · changed (which of value·unit·count·tolerance·text·position changed) · unchanged
bom · notesChanged BOM rows · notes
regionsChanged regions — bbox in the later revision and bbox_old in the prior revision. If alignment fails, status: unavailable with the reason, and regions are not fabricated
summaryHuman-readable summary line — numbers exactly as written on the drawing
confidence · limitshigh · medium · low, and things that could not be compared (scanned page · no title block · BOM only on one side, etc.)
warningsdifferent_drawing_numbers (drawing number differs) · revision_label_unchanged (same revision marker), etc.

Video · Audio — /api/v1/media of rag-api​

This path exists only on installations with video·audio collection turned on (installation env PI_MEDIA_ENABLED=true). On installations where it's off, this path doesn't exist at all, returning 404, and video·audio upload is rejected with 400. Whether it's on is checked via media_ingest_enabled of GET /api/v1/config → Configuring Environment Variables · AI Admin Console — Video

Method · PathWhat it does
GET /api/v1/media/jobsPer-file processing status · duration · segment count · number of scene stills (thumb_count) and processing time bands (window — open · next_start · spec)
GET /api/v1/media/jobs/{asset_id}/{doc_name}Details of a single file
GET /api/v1/media/integritySegments that are indexed but missing from the search index
POST /api/v1/media/jobs/{asset_id}/{doc_name}/retryRe-queue (failed · skipped). If ?force=true, transcribe again from scratch
GET /api/v1/media/{asset_id}/{doc_name}/transcriptThe archived transcript
GET /api/v1/media/{asset_id}/{doc_name}/thumb?t=<ms>The nearest scene still before that time
GET /api/v1/media/{asset_id}/{doc_name}/streamPlayback — responds with 206 to a Range request
  • Uploading uses the same POST /api/v1/docs/upload as documents, and deletion uses the same DELETE /api/v1/docs/{asset_id}/{doc_name} (what comes from the platform ledger is 409).
  • Scene stills (…/thumb) carry ETag and Cache-Control: private, max-age=86400. Sending the same ETag as If-None-Match returns 304 without re-reading the still. If the state DB is briefly busy, it returns 503 with state database busy, retry shortly and Retry-After: 1 instead of 500 — call again after that time.
  • The relay used by the Copilot screen (Copilot's /media/thumb · /media/stream, authenticated with login session) also passes through ETag · 304 as-is, and relays the back end's 503 as 503 with Retry-After (previously wrapped as 502).

Request id and metrics — X-Request-ID · /metrics​

Starting from the 2026.10 re-release, the five Python back ends (RAG · Time Series Insight API · Document Understanding · Vision · Digital Thread) follow the same rule.

  • Request id — If you send X-Request-ID (1~128 chars, letters·digits·. _ -) on the request, that value is returned in the response header X-Request-ID; if missing or malformed, a newly generated value is returned instead. The same value is attached as request_id to log lines that occur while that request is running. RAG also adds request_id to error response bodies.
  • Metrics — GET /metrics emits Prometheus text format. It requires the inbound key (unlike the health path, it is not exempt). The port has not changed → Logs and Monitoring
curl -s -D - -o /dev/null -H "X-Request-ID: my-trace-001" \
-H "X-API-Key: <PI_INBOUND_API_KEY>" http://127.0.0.1:7115/api/v1/config | grep -i x-request-id

Calling the LLM API (OpenAI compatible)​

The LiteLLM proxy provides an OpenAI-compatible API. You can use the existing OpenAI SDK as-is. On the LAN it's http://<server>:4000/v1, and via the default host it's https://<호스트>/llm/v1.

Chat completion​

curl http://127.0.0.1:4000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer 설치-시-변경" \
-d '{
"model": "aura-lite",
"messages": [
{"role": "user", "content": "주입기 베어링 교체 주기를 알려줘."}
],
"temperature": 0.7
}'

Generating embeddings​

curl http://127.0.0.1:4000/v1/embeddings \
-H "Content-Type: application/json" \
-H "Authorization: Bearer 설치-시-변경" \
-d '{
"model": "aura-embedding-4b",
"input": "주입기 베어링 교체 절차"
}'

Checking the model list​

curl http://127.0.0.1:4000/v1/models \
-H "Authorization: Bearer 설치-시-변경"

Available models​

Model namePurposeActual model
aura-liteConversation · analysis (also handles image input)Aura — default Aura-Lite-NVFP4
aura-visionVision-only alias of the same engine as aboveSame model as above
aura-embedding-4bEmbeddingsAura-Embedding-4B
aura-reranker-0.6bRerankingAura-Reranker-0.6B
The old names gpt-4o · text-embedding-3-large now return an error

Older versions accepted the OpenAI-compatible aliases (gpt-4o · gpt-4o-mini · text-embedding-3-large · rerank-multilingual-v3.0). They are no longer accepted — if integration code still has the old names, the call is rejected. Switch to the names in the table above. You can check the currently live names via /v1/models.

Whichever name is used, it routes to the Aura model running in-house — the same applies to air-gapped network installations.


Python SDK example​

Calling the LLM via the OpenAI SDK​

from openai import OpenAI

client = OpenAI(
base_url="http://127.0.0.1:4000/v1",
api_key="설치-시-변경"
)

response = client.chat.completions.create(
model="aura-lite",
messages=[
{"role": "user", "content": "주입기 상태를 분석해줘."}
]
)

print(response.choices[0].message.content)

Calling MCP tools​

import requests

def call_mcp_tool(url, tool_name, arguments=None, headers=None):
response = requests.post(
url,
headers=headers or {},
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": arguments or {}
}
}
)
return response.json()

# 이름으로 설비를 찾는다 (통합 MCP — api_key 필요)
result = call_mcp_tool(
"<platform>/api/v5/mcp",
"search_domains",
{"query": "압축기", "domain": "asset"},
headers={"Authorization": "Bearer <platform.api.token>"}
)
print(result)

# 설비 이상 탐지 (TimeSeries-Insight — 인바운드 키 필요)
result = call_mcp_tool(
"http://127.0.0.1:8970/mcp/",
"detect_asset_anomaly",
{"asset_id": "ASSET_DJ_M_01_1", "lookback_minutes": 1440},
headers={"X-API-Key": "<PI_INBOUND_API_KEY>"}
)
print(result)

Copilot Public API (/api/v1)​

The entry point for external systems to use chat and morning cards as-is. It lives at the same address as chat (the default host).

Method · PathWhat it does
POST /api/v1/chatAsk a question and receive an answer. The server picks whichever tools are needed
GET /api/v1/tasks/dailyRead the day's daily card (generated daily at 05:00). ?date=YYYY-MM-DD — based on the plant time zone; if blank, today
GET /api/v1/toolsThe list of tools the agent can use right now (name·description·read-only flag)
GET /api/v1/ai-feedbackRead markings people have put on AI results (false alarm·suggestion accepted/rejected/effect) and markings the server has made by rule (correct·false alarm·missed) — 2026.10 (below)
curl -X POST https://<호스트>/api/v1/chat \
-H "Content-Type: application/json" \
-H "X-API-Key: <발급받은 키>" \
-d '{ "input_value": "지금 끊어진 엣지 장비가 몇 대야?" }'

The response is answer (markdown) · session_id · duration_ms · tools (the tools called while producing this answer). Omitting or leaving session_id blank opens a new conversation (the same as pressing "New Conversation" on screen — default app, owned by the caller), and that id is returned as session_id in the response. To continue a conversation, include that value in the next request. Since this is a synchronous call, questions that call tools multiple times can take tens of seconds.

In the 2026.09 release, a call without session_id was a 500

The docs said "blank opens a new conversation," but the server returned 500, and calls made with X-API-Key also ended in 500. Both were fixed in 2026.10.

An empty answer returns 502

From the 2026.10 re-release 3 onward, if the model still fails to produce an answer after retrying, it returns HTTP 502. The body shape is the same, with answer left empty. This prevents an empty answer from being received as 200 and displayed as though it were a result — just resend the same question.

Human-applied markings on AI results — GET /api/v1/ai-feedback​

An entry point for reading only the markings people apply to AI results (dismissal reason·accept/reject·effect) within Copilot's Suggestions (the only way to write them is through the screen) → AI Findings and Accuracy — Human-Applied Judgments. Authentication is the same as other /api/v1 (X-API-Key only — from 2026.10 re-release 6, login sessions return 403).

curl "https://<호스트>/api/v1/ai-feedback?since=2026-10-01T00:00:00Z&limit=500" \
-H "X-API-Key: <발급받은 키>"
ArgumentMeaning
sincelabeled_at lower bound (RFC 3339 or epoch ms). If blank, from the beginning
asset_idFilter by equipment id
target_kindANOMALY (one anomaly) · FINDING (AI finding) · PROPOSAL (suggestion card from a finding) · MISSED (missed-case report) · DAILY_TASK
limitMax line count 1~1000, default 500

The response is count · items · next_since. Since results come oldest first, call the next page with next_since — lines at the same timestamp can overlap, so filter with feedback_id. If a person presses again on the same target, that line's value · labeled_at changes (the last marking wins).

label_keyvalue
verdicthit · false_alarm · unsure (judgment withheld — excluded from accuracy calculation)
reasonFalse alarm reasons setup · idle · maintenance · sensor · other / suggestion rejection reasons already_done · not_needed · wrong · other
causeActual cause breakdown · degradation · sensor · setup · maintenance · normal · other
proposal_decisionaccepted · rejected
outcomeeffective · not_effective · unknown (memo in comment)
missedmissed (explanation in comment, missed incident time in occurred_at)

The values people leave on the new screen (the three-menu set) are verdict's false_alarm (dismissal of an anomaly to check) and its reason, proposal_decision, outcome. hit · missed are assigned by the server's stop·fault rule matching, while unsure · cause are in lines left from the old screen.

Each line comes together with occurred_at (the incident time the marking refers to) · labeled_by · labeled_at · config_ver (the judgment version that produced that result), and …Stamp next to the time is written in the plant time zone (PP_TZ).

Keys — two methods​

MethodWhereNature
Issued key (recommended)Copilot Admin Console → API Keys → IssueIssue multiple named keys and revoke individually. Last-used time is tracked
Environment variable keyCO_API_V1_KEY in /etc/kopens/plantpulse-ai.env (CH_API_V1_KEY through 2026.09 — the old name is silently ignored)Only one line. Leaving it blank turns off authentication via this key only
An issued key is shown only once, right after issuance

Since it is stored only as a hash, the list shows only the leading characters. If lost, issue a new one and revoke the old.

This key reaches only the public API

The authority this key grants is /api/v1 and the Copilot MCP (/mcp). From 2026.10 re-release 6, these two paths open only via key, and calling with a logged-in browser session returns 403. The permission·document classification of calls made with this key follow the "external API" role bundle (default: confidential), and scope is always full → Access Control. The admin path and API docs screen (/swagger-ui.html) require admin login. There is no open path to call tools "directly" — tools that change state carry a human approval step.

Changes to APIs used by the screen (2026.10 re-release 3)​

The following are not public APIs but paths the Copilot screen calls with a login session. If you have integrations that mimic the screen, check these.

Method · PathWhat changed
PUT /api/employees/{아이디}/work-role🔴 Now admin only (previously also allowed for oneself). Body {"workRole": "maintenance"}. Each change is left in the audit log as employee.work_role:<아이디> → User Permissions and Work Roles
PUT /api/employees/{아이디}/home-roleNew path — one's own start screen preference. Body {"homeRole": "manager"} (same five values as work roles). Called by oneself or an admin. Does not change approval authority. Since the new screen (the ask·suggestions·automation menu set) always opens to "ask" for everyone, it doesn't use this value — the path remains
GET /api/sop?query=…&assetId=…Gained the assetId argument — when set, excludes sources from other equipment. The response is {answer, sources[], citations, truncated}, and the answer is truncated at a sentence boundary within 600 characters (truncated). Too-short query returns 400; if the document search service doesn't respond, 503 + Retry-After

Response format​

MCP success response​

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "{ ... 도구 실행 결과 JSON ... }"
}
]
}
}

MCP error response​

{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32600,
"message": "Invalid request",
"data": "상세 오류 메시지"
}
}

When volume is large — limit and has_more​

🔴 There is no page · size. Unified MCP tools set an upper bound via limit, and the response signals "there's more" via has_more.

{
"name": "search_domains",
"arguments": {
"query": "압축기",
"domain": "asset",
"limit": 50
}
}
Toollimit defaultMax
search_domains50500
get_tag_history500500
get_tag_values(array tag_ids — at once)100 items
When you encounter has_more: true

Since there are no page numbers, narrowing the condition is the standard approach — specify domain, cut the time range, or make the search term more specific. It is not a form where you keep pushing the same query to get the next page.


Time format​

Time parameters of the unified MCP are server local time. The names are from · to, and the format is YYYY-MM-DDTHH:MM:SS.

{
"name": "get_tag_history",
"arguments": {
"tag_id": "<tag_id>",
"from": "2026-03-29T00:00:00",
"to": "2026-03-29T23:59:59"
}
}
Attaching an offset or Z is rejected

2026-03-29T00:00:00+09:00 or ...Z are not accepted. Only offset-free local time is accepted — check what time the server currently thinks it is via get_current_time.

There are also no names start_time · end_time. They are from · to.

Fields suffixed with _iso in the response are in human-readable KST format:

{
"timestamp": 1711641600000,
"timestamp_iso": "2026-03-29 09:00:00"
}