Skip to main content
A custom integration is a program that reads your tool and writes what it finds to the Coalesce Quality API. It is idempotent — running it twice produces the same estate — so it is a cron job, not a migration. Every snippet below is taken from the runnable examples in getsynq/api/examples. Pick a language in any code block and the rest of the page follows.
The complete program these come from is bi_tool_lineage (Python) — a whole BI tool modelled end to end, run against a live workspace.

Credentials

Create client credentials in API settings — /settings/api in your own app, which is app.synq.io for EU, app.us.synq.io for US and app.au.synq.io for AU. Grant these scopes: Getting Started covers exchanging them for an access token and generating or installing a client — follow it rather than the short version, it is generated from the API itself and stays current. Your region’s API endpoint is in the region table. What is specific to this guide is the environment it expects and the gRPC channel:
Send the token as authorization: Bearer <token> on every call and refresh it when expires_in elapses. Set the gRPC authority explicitly — without it the port travels in the Host header and every RPC returns UNIMPLEMENTED with no message, which reads as a missing service rather than a routing miss:

1. Types

One UpsertType per kind of thing your tool has. Declare traits — they decide how your entities rank, sort and behave platform-wide.
Type numbers are 11000 and workspace-wide, with no allocator. Call ListTypes first and refuse to start if one of your numbers is taken under a different name — that is the only protection against two integrations picking the same band.

2. Entities

One UpsertEntity 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, and an entity group whose members do not all exist is not a set anyone can reconcile against.
Where customID builds the identifier:
The id is a slug; the readable text belongs in name. Keep it to a lowercase A-Z a-z 0-9 : _ . - slug built from the source system’s stable id. Anything else is folded on write — metabase/question/87 and metabase question 87 are the same entity — and ids are case-sensitive, so a capital makes a different one. name takes 500 characters of any UTF-8, which is where the title, the accents and the spaces go. See Entity ids.
To point at an entity from a connected platform, use that platform’s own identifier variant rather than custom:
Where the platform has no structured variant, or you already hold the entity’s opaque entity_id, use the synq_path variant instead — see Finding the entity_id. Annotations are your tool’s own filing system carried across, so the catalog can be filtered the way the tool is browsed. Keep them to labels a person would pick out of a list; prose belongs in the description, which accepts Markdown.

3. Schemas

Declare columns before any lineage step. A SELECT * expands only over an entity whose columns are known, and a declared column edge only shows against a column the entity has. Skip this and the table-level edges still appear, with nothing saying the column ones are missing.

4. Lineage features

SqlDefinition, with bindings

The mechanism to reach for first. Give the SQL as the tool executes it, plus the dialect and a database_context for unqualified names. A name in the SQL that is not a warehouse object — another custom entity, a queue, a service — resolves to nothing and is silently dropped. references binds such a name to the entity it stands for. Names that are warehouse objects need no binding.
The bindings are part of the definition and replace with it: one dropped from a later write is gone, and the lineage it produced is withdrawn.

ColumnLineage

For a component with no SQL at all. State each edge outright. The declaration is complete and replaces the previous one whole, so regenerate it from your tool’s metadata every run rather than diffing.
An upstream on a connected platform works the same way, and does not have to exist yet — the edge is remembered and appears once the entity is ingested. Declaring a column edge also declares the table edge it implies.

Code and Git

Code carries the SQL, Python, YAML or JSON behind the entity for display; several per entity. GitFileReference says where that code lives, so a connected code integration can show recent commits against the entity when it fails.

5. Relationships

For edges with nothing to say at column grain. Read the response — an edge is held between the two entities the endpoints resolve to, so two relationships that look different can be the same edge, and only the last write survives.

6. Checks

Something that validates another entity is not a stage data flows through. Attach it with a check relationship, then give it a CheckCategory feature — attaching schedules no re-publish of the derived check record, while writing a feature does, so the reverse order leaves that record stale until the entity is written again.
Leave category and governance_category unset. They are resolved by your workspace’s own categorisation rules, and a value sent by a producer outranks those rules. Deriving one from your tool’s alert kind — the obvious thing to do — silently switches off every rule the workspace wrote, and nothing in the product explains why.

7. The entity group

One group naming every entity your integration owns, every run. The server diffs it against the previous set and deletes what is missing, so an object deleted in your tool disappears from the catalog with no state kept on your side.
A partial send is a deletion of everything omitted. If reading your tool can fail halfway, skip the group write for that run and let the next complete one reconcile.
A group deletes entities only — not the features, relationships or check relationships of entities that still exist. A repointed dashboard, a removed check or a stale SqlDefinition survives the group write and stays in the graph.
So each run also reconciles what the last one wrote: read the current state with ListEntityFeatures (leave entity_id unset for the whole workspace), ListRelationships and ListCheckRelationships; diff it against what you are about to write; then call DeleteEntityFeature, DeleteRelationships and DeleteCheckRelationships for what is gone — before the group write, so no edge is left pointing at an entity that is about to go.

8. Executions

What gives a custom entity a status rather than just a position in a graph. created_at is required and must be in the past, so a run that has not happened yet is simply not reported.
Status is OK, WARN, ERROR or CRITICAL. UpsertLogEntry attaches log output without changing status.

9. Read it back

Every way a custom integration goes wrong produces a valid-looking write and an empty graph, and the write side cannot tell the difference. End the program by reading its own lineage back. Lineage is computed asynchronously, so poll.
A node carries every identifier that resolves to it, so nodeName picks the shape that says what the node is — prefer the custom variant when present. Check for the edges you expect, not merely that some edge came back. A declared column edge naming a column the upstream does not have still produces one hop while the rest of the chain is lost, so a non-empty result can still be a broken graph. Poll with a deadline, and exit non-zero when it expires rather than logging and returning. For column-level lineage, start from EntityColumnsStartPoint instead, naming the entity and the columns. The response then populates column_dependencies, and each node’s cll_details.cll_state says whether the parse succeeded. A state of RESOLUTION_FAILED on a node is usually a name your SqlDefinition should have bound and did not. This is not a first-version-only step. Keep it in the production integration.

Checklist before you ship

  • Ids are lowercase slugs from the source system’s stable id, in A-Z a-z 0-9 : _ . -
  • The title and any other human-readable text is in name, never in an id
  • Every type declares its traits, and its number was checked with ListTypes first
  • Every entity with columns declares its schema, before any lineage is written
  • Every name in a SqlDefinition that is not a warehouse object has a binding
  • Every upstream resolves, checked with BatchResolveIdentifiers (check_existence: true)
  • Check entities send package and kind, and leave both categories unset
  • One group holds everything, sent whole on every run, never from a partial read
  • feature_id values are stable strings, not generated per run
  • The program reads its own lineage back and fails loudly when it is missing

Next

API surface

Every method, the scope it needs, and the limits

Agent workflow

A page to point a coding agent at, so it writes the integration for you