Read https://docs.synq.io/custom-integrations/agent-workflow and follow it. Write an integration forEverything an agent needs is below. The rest of this section is the same material for a human reader.<your tool>. Its API is at<docs url>.
Your task
Write a program that reads a tool Coalesce Quality has no integration for and publishes it to the Coalesce Quality catalog over the public API: entities, schemas, lineage, checks and run status. The program must be idempotent. It runs on a schedule, not once. Running it twice produces the same estate.Ground rules
Read all of these before writing code. Each one exists because getting it wrong produces a valid-looking write and a broken catalog, with no error anywhere.- The entity id is a lowercase slug built from the source system’s stable id. Restrict it
to
A-Z a-z 0-9 : _ . -, shaped<tool>::<kind>::<source-id>. Every human-readable string — the title, accents, spaces — goes innameanddescriptioninstead. See Entity ids. - Declare schemas before writing any lineage. A
SELECT *expands only over an entity whose columns are known. Skip this and table-level edges still appear while column-level ones silently do not. Note that a declaredColumnLineageedge is not validated against the upstream schema — a typo inupstream_columnis accepted and yields a broken hop, so verification in step 9 is what catches it. - Bind every name in SQL that is not a warehouse object. A
SqlDefinitionresolves names that live at a warehouse address. Anything else resolves to nothing and is dropped with no error. See Binding. - Never send
categoryorgovernance_categoryon a check. They are resolved by the workspace’s own categorisation rules, and a producer value outranks those rules. Sendpackageandkindonly. feature_idis a stable constant, never generated. A fresh id each run creates a new feature instead of replacing the previous one.- The entity group is sent whole, every run. A partial send is a deletion of everything omitted. If reading the source tool can fail halfway, skip the group write that run.
- Read the lineage back at the end and fail loudly. Assert the edges you expect, not merely that some edge exists, and exit non-zero on a timeout or a non-OK CLL state. This is the only thing that distinguishes a working integration from a broken one. Keep it in production.
- Set the gRPC authority when building the channel, or every RPC returns
UNIMPLEMENTED. Step 0 below has the option for each language.
Step 0: setup
Everything you need is on this page. Follow it straight through rather than opening the other pages; they say the same things at more length, and the two links that matter are named at the points where you need them. Credentials come from API settings, at/settings/api in the customer’s own app host
(app.synq.io EU, app.us.synq.io US, app.au.synq.io AU), with these scopes:
Edit Entity Types, Edit Entities, Edit Lineage, Edit Executions, Read
Entities, Read Entity Types, Read Lineage.
Read them from the environment. Never hardcode them.
POST https://$QUALITY_API_ENDPOINT/oauth2/token with
grant_type=client_credentials, and send authorization: Bearer <token> on every call.
Refresh when expires_in elapses.
Use the prebuilt SDKs from buf.build/getsynq/api rather
than generating clients.
Set the gRPC authority explicitly when you build the channel. Without it the port travels
in the Host header and every RPC returns UNIMPLEMENTED with no message — which reads as
a missing service, not a routing problem, and is the single most likely reason a first run
fails entirely:
- Go:
grpc.WithAuthority(endpoint)as a dial option. - Python:
options=(("grpc.default_authority", endpoint),)ongrpc.secure_channel.
If an endpoint above does not resolve, the maintained list is the
region table; token exchange and client generation are
covered in full by Getting Started.
Neither is needed to follow this page.
Step 1: map the source tool
Enumerate the kinds of object the tool has, and for each one decide:- Does it transform data? → trait
is_model - Is it a raw external input? → trait
is_source - Is it read by a person: a dashboard, a report? → trait
is_bi_like - Does it validate another object? → trait
is_test_type - Is it infrastructure nobody wants drawn? → trait
is_ignored_in_lineage
is_model and is_bi_like.
Then decide, per kind of edge between objects, which lineage mechanism applies:
Prefer
SqlDefinition wherever there is SQL. A declaration is a snapshot; a parse follows the
query.
Write this mapping down in the source file as a comment before implementing it. It is the
design, and it is what a reviewer needs.
Entity ids
A custom entity is named byCustomIdentifier { id: "<string>" }.
The id is a slug, not a label. It is a machine identifier. Everything a person reads goes
in name (up to 500 characters, any UTF-8) and description (Markdown) — separate fields
with no restriction. Never put a title, a path with spaces, or accented text in the id; put it
in name.
Build every id to these three rules:
- From the source system’s stable id, never a display name. A title changes; a primary key does not. Changing an id creates a second entity rather than renaming the first.
- From the alphabet
A-Z a-z 0-9 : _ . -only. That is what the platform preserves verbatim. Use::as the separator and prefix with the tool and object kind:<tool>::<kind>::<source-id>. - Lowercased. Ids are case-sensitive, so
Metabase::Question::87andmetabase::question::87are two entities and nothing warns you. Lowercase at the single place you build ids.
zamówienia becomes zam_u00f3wienia), and
every other character folded to _. Two ids differing only in folded characters become the
same entity — metabase/question/87 and metabase question 87 both become
metabase_question_87, second write overwriting the first.
The stored id is custom-<slug>, which is what the API returns as entity_id.
Naming entities you did not create
Lineage points at warehouse tables, dbt models and dashboards that already exist. Use the structured variant for the platform —bigquery_table, snowflake_table,
databricks_table, postgres_table, mysql_table, clickhouse_table, redshift_table,
trino_table, mssql_table, oracle_table, athena_table, fabric_table,
dbt_core_node, dbt_cloud_node, sql_mesh_model, sql_mesh_audit, airflow_dag,
airflow_task, monitor, dataproduct, saved_view. Construct these from the coordinates
the source tool reports.
synq_path is the fallback, for a platform with no structured variant (Looker, Tableau,
Coalesce Catalog and anything newer) or when you already hold an entity’s opaque entity_id.
To obtain one:
- The app URL
<app host>/catalog/<entity_id>/overview— the segment after/catalog/is the id. The app host is regional, matching the API endpoint above. - The MCP server:
search_entitiesand the other read tools. - A webhook payload: each entity carries its
identifierand anentity_urlcontaining the id. BatchIdsByCoordinates, which maps a warehouse name to matching ids.
entity_id is output only — on a request it is ignored, and the structured variant is what
resolves. To name an entity by opaque id, set synq_path to that string.
Resolve before you write
synq.entities.resolve.v1.IdentifierResolveService.BatchResolveIdentifiers turns an
identifier of any shape into its entity_id, and with check_existence: true says whether
the entity exists.
The two kinds of upstream differ, and this is what tells them apart:
- A custom entity that does not exist is rejected at the write, so a misspelled custom id surfaces immediately.
- An entity on a connected platform is not checked; it binds on next ingestion. So a typo’d warehouse name and a not-yet-crawled table are indistinguishable in the lineage afterwards.
BatchResolveIdentifiers over every connected-platform upstream and fail loudly on
anything that does not resolve.
It also returns the entity’s identity group (identities, identity_synq_paths) — the
other identifiers that name the same entity, such as a dbt model and the warehouse table it
builds. Use it to detect that two upstreams you were treating as different are one entity
before writing two relationships that collapse into one edge.
Use the read API as input
Do not hardcode a table of entity ids. Resolve them at run time:
Declaring column lineage against columns read from
GetSchema, rather than columns the
source tool believes exist, is the difference between edges that appear and edges that do not.
Step 2: types
synq.entities.custom.v1.TypesService.UpsertType, one per kind.
type_idis1–1000, workspace-wide, with no allocator.- Call
ListTypesfirst and refuse to start if one of your numbers is taken under a different name. Two integrations picking the same band is otherwise silent corruption. nameis a short caption shown on a badge, max 100 chars:"Kafka Topic", not a sentence.svg_iconis required, raw SVG bytes, max 1 MB.traitsfrom step 1.
Step 3: entities
synq.entities.custom.v1.EntitiesService.UpsertEntity, one per object.
Create every entity before anything points at one. A SQL binding to an entity that does
not exist is rejected at the write.
id—CustomIdentifier, from the source system’s stable id.type_id— from step 2.name— as the tool shows it, max 500 chars. Do not shorten it to fit.description— Markdown, max 10 000 chars.annotations— the tool’s own filing system: collection, folder, workspace, object kind. Max 20 annotations, each with a name (max 50 chars) and up to 20 values (max 100 chars). These are what people filter the catalog by, so keep them to labels, not prose.
synq_path, synq_catalog_url, ids or synq_paths — they are returned, not sent.
Step 4: schemas
FeaturesService.UpsertEntityFeature with a schema feature, feature_id: "schema".
Do this before step 5. Columns carry a name, native type, description and ordinal
position. Give positions for all columns or for none. Max 1000 columns; struct and repeated
types nest through fields.
Step 5: lineage
SqlDefinition
feature_id: "sql". One per entity.
dialect— one ofSQL_DIALECT_{BIGQUERY,CLICKHOUSE,DATABRICKS,MYSQL,POSTGRESQL,REDSHIFT,SNOWFLAKE,DUCKDB,TRINO,MSSQL,ORACLE,ATHENA,FABRIC}.sql— as the tool actually executes it, max 1 000 000 chars.database_context— what unqualified names in the SQL resolve against.references— see below. Max 200.
Binding non-warehouse names
ASqlDefinition can only resolve names that live at a warehouse address. A saved query, a
queue, a service, an API or another custom entity has no address, so FROM my_upstream
matches nothing, is dropped, and the entity ends up with no upstream at all and no error.
An empty result is indistinguishable from a query that genuinely reads nothing. This is the
single most common way a custom integration ships broken.
references binds a name, as the SQL writes it, to the entity it stands for:
- Matching is case-insensitive.
- A binding beats a real warehouse table of the same name — use it as a deliberate override when you need one.
- Empty parts default from
database_context, exactly as an unqualified name in the SQL does. - Names that are warehouse objects need no binding. One definition can read a bound custom entity and a raw table in the same query, binding only the first.
- Bindings replace with the definition. One dropped from a later write is gone.
ColumnLineage
feature_id: "column-lineage". One per entity, max 10 000 edges.
Use when there is no SQL for a parser to read. Each ColumnEdge is upstream (an
Identifier), upstream_column and column. The two sides are named independently and need
not match.
- It replaces whole. An edge missing from a write is withdrawn. Regenerate it from the tool’s metadata every run rather than diffing.
- A column edge declares the table edge it implies. Do not also write a
Relationship. - It wins over
SqlDefinition. An entity with both is served from this declaration. So either declare only what the SQL cannot say, or make this the authority for the entity’s whole column lineage. - An upstream that does not exist yet is remembered, and the edge appears once it does.
state_atdefaults to now; set it explicitly when backfilling.
Relationship
RelationshipsService.UpsertRelationships, for edges with nothing to say at column grain.
At least one endpoint must be a custom entity — two native entities cannot be joined.
Read the response. Each RelationshipWriteResult names the resolved endpoints and an
outcome of CREATED, UPDATED, DELETED or NOT_FOUND. An edge is held between the
entities the endpoints resolve to, and one entity accepts several spellings, so two
relationships that look different can be the same edge — and only the last write survives.
Code and Git
Optional but worth including:codefeature (feature_idper file or function, several allowed): the SQL, Python, YAML or JSON behind the entity, max 100 000 chars, with acode_typeofCODE_TYPE_{SQL,PYTHON,JSON,YAML,DBT,SQLMESH,LOOKML,JAVASCRIPT,BASH,DAX,JAVA,SCALA,TEXT,MARKDOWN}.git_file_referencefeature: repository URL (SSH preferred), branch, file path. With a code integration connected, Coalesce Quality reads the file’s history and shows recent commits against the entity when it fails.
Step 6: checks
For objects that validate other objects — an alert, a test, a monitor in the source tool.ChecksRelationshipsService.UpsertCheckRelationshipswithcheck,checkedand optionalchecked_columns.check_categoryfeature,feature_id: "check", carryingpackage(a grouping, max 50 chars) andkind(what the tool calls it, max 50 chars).
category or governance_category. They are resolved by the workspace’s own
categorisation rules from package and
kind. A producer value outranks those rules, so deriving one from the tool’s alert kind —
the obvious thing to do — silently switches off every rule the workspace wrote, with nothing
in the product explaining why.
A check is not a stage data flows through. Do not also give it a lineage edge.
Step 7: the entity group
GroupsService.UpsertEntitiesGroup with one group_id for your integration and every
entity id it owns.
The server diffs your set against the previous one and deletes what is missing. That is what
makes deletion work without keeping state on your side.
deleted_ids from the response.
So the run also has to reconcile what it wrote last time:
- Read the current state:
ListEntityFeatures(withentity_idunset for the whole workspace),ListRelationships,ListCheckRelationships. - Diff against the state you are about to write.
- Withdraw what is gone, before the group write:
DeleteEntityFeature,DeleteRelationships,DeleteCheckRelationships. - Then write the group, which removes the entities themselves.
Step 8: executions
EntityExecutionsService.UpsertExecution, so entities have health rather than just a position
in a graph.
status—EXECUTION_STATUS_{OK,WARN,ERROR,CRITICAL}.created_at— required, must be in the past, rejected before 2022-01-01.started_at/finished_at— for long runs.extras.executed_sql— the SQL that ran.
UpsertLogEntry attaches log output without changing status.
Step 9: verify
CallLineageService.GetLineage with lineage_direction: LINEAGE_DIRECTION_UPSTREAM and a
start_point.entities naming an entity at the bottom of your graph. Lineage is computed
asynchronously, so poll with a deadline.
A non-empty result is not success. Assert the specific nodes and edges you expect. A
declared column edge with a typo’d upstream_column still produces one hop while the chain
beyond it is silently lost, so “at least one edge exists” passes on a graph that is broken.
Fail the program, with a non-zero exit status, on any of:
- an expected node or edge missing from the response,
- a node whose
cll_details.cll_stateis neither OK nor unspecified —RESOLUTION_FAILEDis usually a name you should have bound in step 5 and did not, - the poll deadline expiring, or the read erroring.
Definition of done
Check each of these before reporting the work complete:- Ids are lowercase slugs from the source system’s stable ids, in
A-Z a-z 0-9 : _ . - - No title, accent, space or other human-readable text is in an id; it is in
name -
ListTypesis called before claiming type numbers - Every type declares its traits
- Every entity with columns declares its schema, before lineage is written
- Every non-warehouse name in a
SqlDefinitionhas areferencesbinding - Every upstream is resolved with
BatchResolveIdentifiersbefore lineage is written - Checks send
packageandkindonly - All
feature_idvalues are constants - The group is sent whole, and skipped entirely on a partial source read
- Obsolete features, relationships and check relationships are explicitly deleted; the group write alone does not remove them
- Credentials are read from the environment
- The program polls
GetLineageat the end and exits non-zero when it is empty - Running the program twice produces no change on the second run
Reference
- Modelling your tool — the decisions in more depth
- Building an integration — the same steps with Go and Python code
- API surface — every method, scope and limit
- gRPC API reference — generated from the protos
- API scopes — every scope and the methods it unlocks
- Protos and SDKs
- Examples — runnable Go and Python