It scans and aggregates the Neo4j ontology. You cannot create containers (nodes/attributes) here — that is determined by the ontology design document. What each of your four entry points can do is shown side‑by‑side in the table of Zio.Database (overview)
You can query graph data using method chaining on the built‑in Graph object.
Method
Description
Sample
.schema()
The cell definition of this label. {label, defined_in:[{twin_id, twin_title, display_property, properties[...]}]}. A name may appear in multiple design documents, so it also indicates which document it came from — if the cells differ, that is what you will see.
Zio.node("Inquiry").schema()
.available_edges()
The outgoing and incoming relationships of this label. {out:[{rel, to}], in:[{rel, from}]}. The names you put into .out(rel, to=...) are here — do not guess. Relationship names are created by the designer; HAS_PRIORITY and hasSentiment can appear together in one design document.
Zio.node("Inquiry").available_edges()
Zio.node.labels()
The labels that currently exist in the graph and their counts. [{label, count}]. Zio.ontology.list() is “what was drawn”, this is “what currently exists” — seeing a mismatch shows the usefulness of this value.
Zio.node.labels()
Zio.node(label)
Specify the label of the node to start traversal from.
Zio.node("Inquiry")
.where(**kwargs)
Filter on properties of the currently focused node. Suffixes for comparison are supported — see the Comparison suffix table below. Multiple arguments are combined with AND.
.where(status="OPEN", yyyymmdd__gte="2026-03-01")
.where_or(**kwargs)
Combine multiple conditions with OR (and AND with other wheres). Use when any one of several fields matches — previously you had to split the query and combine in Python. Suffixes are the same as .where(). You can OR up to 1‑hop nodes beyond relationships — first declare a hop with alias and mix with keys like alias.property. The alias must be a declared hop (otherwise it fails; you cannot guess the relationship name). Keys without dots refer to the currently focused node, so for cross‑node OR you must also specify the base node as an alias. The nodes returned are determined by .select() (if omitted, the last hop is returned). Search OR uses optional_out — a required hop will drop rows that have no relationship. Each call creates a separate OR block that is ANDed with other blocks.
Follow a relationship in the forward direction (→) one hop. to is the label of the destination node, alias is the name you attach there (if omitted it’s derived from the label), from_ is where you branch from (if omitted it’s the previous node).
.out("CREATED_BY", to="Customer")
.in_(rel_type, to, alias, from_)
Follow a relationship in the reverse direction (←) one hop. The other party points at me. Arguments are the same as .out().
.in_("DUE_ON", to="Order")
.with_relations(only=None)
Attach all 1‑hop relationships to the node in a single step. Each result row is the node’s properties plus an additional relations field — e.g. {"Priority": [...], "Category": [...]}. If no argument, it attaches all of .available_edges()["out"]; you can also filter by target label list or {"rel":…, "to":…}. See the “relationships in one go” section below.
.with_relations(["Priority", "Category"])
.select(**kwargs)
Specify which properties to return, optionally with aliases (projection). In multi‑join scenarios you can also retrieve intermediate node or relationship attributes. The left side is the alias you assign; the right side is name.property. If omitted it’s derived from the label — see “node alias names” below.
Specify result ordering. Prefix a name with - for descending. Multiple fields are prioritized in order. When used with .select(), you sort by the alias you attached; you cannot sort on values that were not returned. If no .select() is used, you sort by the node’s property names directly. .code() and .entity() only return a single column name each.
.order_by("-work_date", "work_name")
.group_by(**fields)
Specify what to group by. Same shape as .select(). The grouping key is included in the result. Must be used with .aggregate() — if you give only .group_by() it will not group and will return the last node unchanged (a console warning appears). If you want to pick specific fields, use .select().
.group_by(status="work.status")
.aggregate(**specs)
Specify what to count per group. Only Count, Collect, First, Sum are allowed. You can give a where to each aggregate to count only those that match the condition.
.aggregate(works=Count("work", distinct=True))
.count(of=None, distinct=False)
Return one count (a number). It does not pull a list and then call len(). Grouped counts are obtained with .group_by() + .aggregate().
Zio.node("Work").where(status="완료").count()
.limit(val)
Explicitly limit the maximum number of rows returned.
.limit(10000)
.create(props)
Create a new node with the specified properties (CREATE). The display name (_display) is automatically calculated by the SDK.
Create if missing, otherwise update. What constitutes “the same” is determined by .where() (the equality condition). on_create runs only when creating; on_match runs only when a match exists. This differs from first querying and then calling .create(), because another operation could create the same node in between.
Consolidate nodes that match the condition into one other node of the same label. All relationships are moved to the target node and the source node disappears. Use when you want to merge split nodes — using .update() to rename will create two nodes with the same name, but relationships won’t follow.
Zio.node('Client').where(client_name='GS THE FRESH').consolidate_into(client_name='GS더프레쉬')
.optional_out(...) / .optional_in(...)
Same hop as .out()/.in_(), but the row remains even if the relationship does not exist (OPTIONAL MATCH). Arguments are the same as .out(). Caution: filtering a node after an optional hop with .where() is not a filter — because of OPTIONAL MATCH the preceding rows are not filtered and the node’s properties become null (same result as no condition). To actually filter by that relationship, use .out()/.in_() (required match) or the helper .where_out(). (A warning appears if you put a where on an optional hop.)
Required match hop + target filter in one line. Equivalent to .out(rel, to).where(target_filter) but you don’t need to move the cursor back; it also ensures that rows not matching the condition are properly filtered (avoids the optional trap). Filter suffixes are the same as .where().
Based on the chain built above, safely generate and execute a Cypher query and return the result as a GraphResult object.
res = Zio.node("Inquiry").fetch()
.to_cypher()
Return the assembled Cypher and parameters as a string. It does not execute. Use to see how your chain was translated for debugging — even if you don’t have Neo4j tools in an SSH environment. It shows only the translation of your chain (not a schema dump), and raw input cannot be supplied here.
This was the place where you could pass Cypher literally. In skill‑enabled areas there is no engine source or DB connection, only the SDK itself; this path never passes through that gate. Calling it tells you what to switch to in words.
Use the functions above instead — they follow graph changes but hand‑written Cypher will silently drift.
Labels, relationship, and property names are placeholders for schema references, while the right side of .where() is a value to filter on. These are different — values safely become parameters, names go straight into the query text. Therefore never insert user input or LLM‑generated strings directly into name positions.
# 위험 — 바깥에서 온 값을 라벨·이름 자리에
label = user_input # 예: LLM 이 고른 라벨
Zio.node(label).where(**{user_key: 1}) # label·user_key 가 그대로 질의에 박힌다
# 안전 — 이름은 내가 아는 것으로 고정, 바깥 값은 '값 자리'로
Zio.node("Inquiry").where(subject__contains=user_input) # user_input 은 파라미터로 나간다
If an odd character appears in a name placeholder, the SDK will block and explain why (non‑ASCII, non‑underscore label/relationship, weird property name). To pick a consistent label, use Zio.node.labels() to get the actual existing names.
Use when you want to see everything attached to this node at once. .out() follows one relationship per query; if there are nine relationships you would have to ask nine times. .with_relations() folds that into a single query.
The numbers are the actual counts from the helpdesk instance. Inside CALL (n) { OPTIONAL MATCH … RETURN collect(…) } the number of rows is not multiplied by the relationship count — missing relationships return an empty list.
Only one hop deep. Relationship‑of‑relationship is not followed. Increasing depth turns a single node graph into thousands of rows. If you need two hops, use .out().
Cannot be combined with select, group_by, aggregate, or out. These change what constitutes “one row”, so they are blocked. Process the result in Python afterward.
If names collide, relationships are also attached. When there are two paths to the same label, the cell name becomes HAS_PRIORITY:Priority instead of just Priority — this prevents one from overwriting the other.
Append __suffix to a key in .where() to change the comparison method. Without a suffix it is =. The same names work for both ontology (Cypher) and custom tables (SQL).
All names come from your design; the SDK only knows the suffixes.
The title, qty shown in the table below are just examples, not SDK‑defined names. Node and field names are taken directly from the industry ontology design, just like when writing SQL.
SELECT * FROM Worklog WHERE subject LIKE '%GS25%'
└ 노드 ┘ └ 필드 ┘ └ 비교 ┘
Because industries differ, the names differ entirely.
Only the word after __ is understood by the SDK (see below). If omitted it defaults to =. Multiple conditions mean all must be satisfied (AND).
The engine never sees your field name; if you use a non‑existent name, the result will be empty or an error — typos become obvious.
Suffix
Meaning
Example
__gt / __gte
Greater than / greater or equal
.where(qty__gte=100)
__lt / __lte
Less than / less or equal
.where(yyyymmdd__lte="2026-03-31")
__ne
Not equal
.where(status__ne="CLOSED")
__in
In a list. Value must be a list (array). An empty list matches nothing
.where(client_name__in=["GS25", "보나캠프"])
__startswith / __endswith
Starts with / ends with
.where(email__endswith="@zio.run")
__contains
Contains
.where(title__contains="장애")
__contains_all / __contains_any
Value is a list. All contained / any contained. Used when you split the search string by spaces — you cannot apply two __contains to the same field. An empty list matches nothing for __contains_any.
.where(work_name__contains_all=["GS25", "포스"])
__isnull
True means no value, false means has a value
.where(closed_at__isnull=True)
Unknown suffixes raise an error.
A typo like __gtee will return an error and list the usable suffixes. Previously the condition would silently disappear and all rows would be returned — no error was raised. If your property name itself contains __, it will be interpreted as a suffix; rename it.
Only four functions can be aggregated. You do not pass function names as strings — writing something like aggregate(cnt="count(*)") is just a shorter Cypher expression, not wrapped by the SDK.
Aggregate
What
Example
Count(of, distinct, where)
Count. If of omitted it’s count(*).
Count("n", distinct=True)
Collect(of, distinct)
Gather values into a list.
Collect("client.client_name", distinct=True)
First(of, distinct)
The first of the gathered ones. Useful when you have one value like department or title but the path causes multiple hits.
Aggregates that receive a where count only those that match the condition. The syntax is the same as .where() — comparison suffixes are used unchanged.
After aggregation you are left with only the grouping key and aggregate results; original node properties have been folded away, so sorting on them will fail.
You can embed a list in any prompt that goes to an AI: {{#node.property}} or {{$table.field}}. Right before sending the model out, it is replaced with the actual list.
Where you can write
What
Domain / role prompt
Base code → each row of system_prompt table
Data source description
Pipeline builder → description field
Field instruction / output format
Pipeline schema → AI Prompt · Output Format
Agent node prompt
Agent builder → AI model call node
Future prompt cells will behave the same. Replacement happens only once at the model‑call gate, so even if new screens appear you don’t need to add anything.
Syntax
What comes in
{{$job_type.type_name}}
The preset list of values from base code. If a role=prompt field exists it also includes descriptions
{{#Client.client_name}}
List of nodes already stored in the ontology
{{#Client.client_name?limit=50}}
Display limit. Omit for 200 max; if truncated, it is logged
{{$job_type.type_name?use=Y}}
Filter. Only rows that match
{{$job_type.type_name?use=Y&grade=A,B}}
Multiple filters. Connect with &; use commas inside a value to mean OR. Do not wrap the whole thing in an extra pair of braces — {{{$…}}} leaves the outer braces as text
Output Format 에 이렇게 적으면
{
"client": "거래처 명칭. 아래에 같은 뜻이 있으면 그 이름을 그대로 쓰십시오: {{#Client.client_name}}",
"work_type": "다음 중 하나만: {{$job_type.type_name}}. 다른 값을 만들지 마십시오"
}
모델에게는 이렇게 갑니다
{
"client": "거래처 명칭. 아래에 같은 뜻이 있으면 그 이름을 그대로 쓰십시오: GS25, 한화비전, 쿠팡, ...",
"work_type": "다음 중 하나만: 설치, 교체, 철수, 점검, ... 다른 값을 만들지 마십시오"
}
Write directives yourself # (stored) and $ (preset) are opposite: one means “create if missing”, the other means “don’t go outside the list”. Put the instruction next to the list. The system will mix them if you don’t specify.
If a reference cannot be found or returns 0 rows, {{...}} stays as is. An empty value leaves a fragment like “choose one of: ” which confuses the model.
Node alias names .select(), .order_by(), aggregates, and from_ refer to these names. If omitted they’re derived from the label — Zio.node("Worklog") becomes worklog, .out("HAS_WORK", to="Work") becomes work.
Default name: first letter of the label lowercased. Work → work, WorkType → workType.
Same label twice:work, work2 (append a number). If confusing, specify with alias=.
Relationship attributes are read as <targetname>_rel — e.g., work_rel.qty.
Names are used in SQL/Cypher like a FROM clause (FROM worklog wl, (w:Work)).
> Zio.node("Worklog") # 이름: worklog
> .out("HAS_WORK",to="Work") # 이름: work
> .optional_out("FOR_CLIENT",to="Client") # work 에서 이어짐
>**If the path splits, specify with`from_` where it branches.** If omitted it continues from the last arrived node — omitting `from_` above would try to find an author from the client andreturn nothing. Giving a non‑existent name blocks and tells you what names are usable.
> Names come from labels, so inserting an intermediate hop does not shift subsequent names. Earlier versions used `t0`, `t1` which pushed everything else.
>**Consolidation (`.consolidate_into()`) rules**
>-**Target must be the same label.** If you start with`Zio.node("Client")`, the target is also `Client`. Cross‑label consolidation isn’t supported.
>-**Attributes are taken by the target node.** If both have the same attribute, the target’s value remains; attributes missing on the target come from the source.
>-**Relationships are all moved.** Duplicate relationships of the same type to the same target are merged into one.
>-**If no target exists, it simply renames.** It behaves like `.update()`; the result is a single node with that name.
>-**If no matching nodes exist, nothing happens.** Re‑running yields the same result (idempotent).
>-**No rollback.** The absorbed node disappears. Verify what will merge by calling `.fetch()` first in your skill.
> Return value is the surviving target node; if nothing merged you get an empty result — check `.data` to see if anything changed.
>**Automatic `_display` calculation rules**
> Every node has a display name stored as`_display`. You don’t set it manually. When `.create()`or`.update()` runs, the SDK reads the Display Property defined in the ontology design and calculates it automatically. It shares the same calculator (`graphdb/display.py`) used by the writer.
>-**Single property name:** If the design says `verdict`, that value becomes `_display`. → `_display = "위험"`
>-**Brace template:**`{[sequence]차}{by}` etc. Values are substituted. → `_display = "[1차]mail.example.com"`
>-**Missing values** become an empty string. If the design has no Display Property, `_display`is untouched.
>-**Calculation failures do notraise exceptions.** The SDKis designed so that a bad display name does not abort the skill’s storage operation.
> Rules come from the `status=live` design. Inside the API container it reads PostgreSQL directly; skills running in a Zero‑Trust sandbox fetch rules via `GET/api/sdk/ontology/display-rules`. Only `{ node: Display Property }`is exported, not the whole design. It caches per process, so after changing the design you must restart the skill for new rules to apply.
>**Design changes are not retroactive.** Changing a Display Property does not alter `_display` on already stored nodes; only new or updated nodes use the new rule.