Skip to main content
This page is written to be handed to a coding agent. Point it here and describe your tool:
Read https://docs.synq.io/custom-integrations/agent-workflow and follow it. Write an integration for <your tool>. Its API is at <docs url>.
Everything an agent needs is below. The rest of this section is the same material for a human reader.

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.
  1. 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 in name and description instead. See Entity ids.
  2. 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 declared ColumnLineage edge is not validated against the upstream schema — a typo in upstream_column is accepted and yields a broken hop, so verification in step 9 is what catches it.
  3. Bind every name in SQL that is not a warehouse object. A SqlDefinition resolves names that live at a warehouse address. Anything else resolves to nothing and is dropped with no error. See Binding.
  4. Never send category or governance_category on a check. They are resolved by the workspace’s own categorisation rules, and a producer value outranks those rules. Send package and kind only.
  5. feature_id is a stable constant, never generated. A fresh id each run creates a new feature instead of replacing the previous one.
  6. 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.
  7. 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.
  8. 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.
Exchange for a token at 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),) on grpc.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.
Before writing code, read one of the complete examples: Go, Python. They are verified against a live workspace and cover every step here.

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
Traits combine. A saved query that transforms data and is read by a person is both 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 by CustomIdentifier { 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:
  1. 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.
  2. 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>.
  3. Lowercased. Ids are case-sensitive, so Metabase::Question::87 and metabase::question::87 are two entities and nothing warns you. Lowercase at the single place you build ids.
Ignore this and the write normalises silently: truncated at 200 bytes (hash tail if longer), non-ASCII replaced by its literal escape text (zamówienia becomes zam_u00f3wienia), and every other character folded to _. Two ids differing only in folded characters become the same entitymetabase/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 platformbigquery_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_entities and the other read tools.
  • A webhook payload: each entity carries its identifier and an entity_url containing 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.
Run 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_id is 11000, workspace-wide, with no allocator.
  • Call ListTypes first 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.
  • name is a short caption shown on a badge, max 100 chars: "Kafka Topic", not a sentence.
  • svg_icon is required, raw SVG bytes, max 1 MB.
  • traits from step 1.
Keep the numbers in a constant table at the top of the file.

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.
  • idCustomIdentifier, 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.
Never set 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 of SQL_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

A SqlDefinition 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_at defaults 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:
  • code feature (feature_id per file or function, several allowed): the SQL, Python, YAML or JSON behind the entity, max 100 000 chars, with a code_type of CODE_TYPE_{SQL,PYTHON,JSON,YAML,DBT,SQLMESH,LOOKML,JAVASCRIPT,BASH,DAX,JAVA,SCALA,TEXT,MARKDOWN}.
  • git_file_reference feature: 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.
  1. ChecksRelationshipsService.UpsertCheckRelationships with check, checked and optional checked_columns.
  2. check_category feature, feature_id: "check", carrying package (a grouping, max 50 chars) and kind (what the tool calls it, max 50 chars).
That order matters. Attaching a check relationship schedules no re-publish of the derived check record, while writing a feature does. Writing the feature first leaves the check’s stored state stale until the entity is written again. If you ever delete a check relationship, re-write the feature afterwards for the same reason. Do not set 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.
A partial send is a deletion of everything omitted. If the source read can fail partway, skip the group write for that run. Log deleted_ids from the response.
A group deletes entities only. It does not remove features, relationships or check relationships belonging to entities that still exist. A dashboard repointed at a different model, a check that was removed, a SqlDefinition that is no longer relevant — none of those are cleaned up by the group write, and they stay in the graph indefinitely.
So the run also has to reconcile what it wrote last time:
  1. Read the current state: ListEntityFeatures (with entity_id unset for the whole workspace), ListRelationships, ListCheckRelationships.
  2. Diff against the state you are about to write.
  3. Withdraw what is gone, before the group write: DeleteEntityFeature, DeleteRelationships, DeleteCheckRelationships.
  4. Then write the group, which removes the entities themselves.
Withdrawing edges before deleting entities is the order that leaves no dangling references.

Step 8: executions

EntityExecutionsService.UpsertExecution, so entities have health rather than just a position in a graph.
  • statusEXECUTION_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.
Report what the tool tells you and nothing more. A run that has not happened yet is simply not reported. UpsertLogEntry attaches log output without changing status.

Step 9: verify

Call LineageService.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_state is neither OK nor unspecified — RESOLUTION_FAILED is usually a name you should have bound in step 5 and did not,
  • the poll deadline expiring, or the read erroring.
A timeout that logs and returns normally is the failure mode to avoid: it reports success for a graph that never materialised.

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
  • ListTypes is 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 SqlDefinition has a references binding
  • Every upstream is resolved with BatchResolveIdentifiers before lineage is written
  • Checks send package and kind only
  • All feature_id values 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 GetLineage at the end and exits non-zero when it is empty
  • Running the program twice produces no change on the second run

Reference

Questions that this page does not answer go to your Technical Account Manager — see Support.