Huck retrieves from the records your backend publishes, using the current user's authorized context. Push your actual domain objects when they change: orders, products, variants, tickets, policies, or any other named record type. You do not need to convert everything into a web page or flatten it into one string.
Use a backend service key with knowledge:write; add knowledge:read for reconciliation. To search as an end user, use a chat session. A service key is not a substitute for an end user's access context.
Identity and content
Report an issue with this sectionA record is identified by workspace + external_type + external_id. order/42, product/42, and document/42 are different records. Omitting external_type means document, including delete and retry operations.
| Field | Contract |
|---|---|
external_type |
Optional; starts with a letter, up to 80 letters, numbers, _, -, or .. Use consistent case. |
external_id |
Required integer or opaque nonempty string, up to 200 characters. Numeric IDs become strings; GUIDs are accepted. Booleans and floats are not IDs. |
revision |
Positive integer; a new record defaults to 1. Every update/restoration requires an explicit revision. |
title |
Optional, up to 250 characters. A new record defaults to its type and ID. |
content |
Required for creation/restoration; a string, object, or array with nested JSON values. Searchable facts go here. |
metadata |
Optional JSON object retained alongside content; it is not appended to retrieval text. |
relationships |
Optional list of {name, external_type, external_id} references. |
gates |
Optional map of registered access keys. See access semantics below. |
idempotency_key |
Optional per-record replay key, up to 120 characters. A single-record header overrides the body value. |
Opaque strings preserve significant characters: "0042" is not 42. Send large identifiers as strings in JavaScript to avoid precision loss before a request leaves your app. Unsupported unrepresentable JSON integers in content/metadata are rejected, rather than silently changing their values; use a string for exact large numbers or decimal amounts.
Huck preserves the structured source payload and derives deterministic path-aware text for retrieval. Object-key order does not change the canonical source; array order does. Strings, booleans, null, empty arrays and empty objects remain distinct. Existing string content continues to work.
Publish a nested order
Report an issue with this sectionSave this as order-42.json in your integration's private working directory:
{
"external_type": "order",
"external_id": 42,
"revision": 1,
"title": "Alice's order 42",
"content": {
"customer": {"name": "Alice"},
"status": "paid",
"currency": "USD",
"total": "79.00",
"items": [
{"product_id": "boot-7", "quantity": 1,
"variant": {"size": "8", "color": "brown", "waterproof": true}}
],
"delivered_at": null
},
"metadata": {"source": "commerce", "schema_version": 1},
"relationships": [{"name": "customer", "external_type": "customer", "external_id": "alice"}],
"gates": {"org_id": "2", "user_id": "4"}
}
curl --fail-with-body "$HUCK_API_URL/api/v1/knowledge" \
-H "Authorization: Bearer $HUCK_INGEST_KEY" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-42-revision-1' \
--data-binary @order-42.json
A 202 response means accepted, not already indexed:
{"document":{"id":123,"external_type":"order","external_id":"42","revision":1},"accepted":true}
Use the manifest or console indexing state before depending on the new content in chat. The response's internal numeric id is useful for pagination, but future writes still use your external type and ID.
Gates and named hierarchy context
Report an issue with this sectionEvery supplied gate key is combined with AND; values within one gate are alternatives. For example, {"org_id":"2","permission_id":["orders.read","support.read"]} requires organization 2 and one matching permission. Gate names must exist in your published policy. Custom keys are supported through the access editor/API.
When your hierarchy declares user_id below org_id, both records and identities containing user_id must include org_id. Every ancestor must carry exactly one value when its descendant is present. Two organizations and one user value do not describe two separate branches; publish separate contexts/records when necessary. Policies without a configured parent remain flat. Read the access guide before assigning delegated manager permissions.
On a new record, omitted gates use the workspace default audience. Explicit {} is company-wide only when company-wide information is allowed by policy. On an update, omitted gates preserve the existing gates. Removing a key from an explicitly supplied gate map changes the audience; send a complete intended map and a higher revision.
Readable labels such as “Juniper Market” or “Alice” live in access context, not in the credential. They explain an already-authorized relationship; they do not grant one. Likewise, a record's relationships list is a reference, not permission. A link to a customer does not make the customer's records visible and does not automatically expand their content into a prompt. Ingest related records independently with their own gates.
Updates and replay safety
Report an issue with this sectionPersist the source revision and outbox payload in the same transaction as your own domain change. A queue worker can then send that exact payload with a stable idempotency key. Do not allocate a new revision merely because a network request timed out.
Laravel worker example, assuming your application's outbox stores the exact JSON record as the string raw_payload:
$response = Http::baseUrl(rtrim(config('services.huck.url'), '/').'/api/v1/')
->withToken(config('services.huck.key'))->acceptJson()
->withOptions(['allow_redirects' => false])
->withHeaders(['Idempotency-Key' => $outbox->idempotency_key])
->connectTimeout(5)->timeout(20)
->withBody($outbox->raw_payload, 'application/json')->post('knowledge');
if ($response->status() === 409) {
// Mark this outbox row for reconciliation; do not overwrite newer state.
throw new RuntimeException('Huck source revision needs reconciliation.');
}
$response->throw();
if ($response->status() !== 202) {
throw new RuntimeException('Huck did not acknowledge source acceptance.');
}
// Mark accepted in your outbox. Check indexing separately when needed.
Rules to implement in your synchronizer:
- A greater revision advances the record.
- The same revision with the same effective data is accepted; different data at that revision returns
409. - An older revision returns
409. - The same idempotency key with the same payload returns the original acceptance receipt. It does not restore a later-deleted record.
- The same idempotency key with changed payload returns
409. - An update may omit content, title, metadata, relationships, or gates to preserve their existing values. A supplied object/array replaces that field; nested content is not merged automatically.
For a gates-only change, use {"external_type":"order","external_id":42,"revision":2,"gates":{"org_id":"2","user_id":"5"}}. Unchanged retrieval text can reuse its embeddings; access changes invalidate the relevant workspace authorization epoch. Obtain a fresh chat session when the prior session becomes invalid.
Atomic batches
Report an issue with this sectionPOST /knowledge/batch accepts 1–50 records. The request is atomic: a validation/revision/quota failure rolls back the batch acceptance. Do not include the same type/ID twice. Use an idempotency key inside each record; the batch endpoint does not define a batch-wide replay receipt from the header.
{
"records": [
{"external_type":"product","external_id":"boot-7","revision":1,
"content":{"name":"Brown boot","material":"leather"},
"gates":{},"idempotency_key":"product-boot-7-r1"},
{"external_type":"order","external_id":42,"revision":3,
"content":{"status":"shipped","tracking_reference":"SHIP-42"},
"gates":{"org_id":"2","user_id":"4"},"idempotency_key":"order-42-r3"}
]
}
The example product uses company-wide visibility; confirm that this is your intended audience. A missing record in a batch is never interpreted as deletion.
Reconcile, delete, and retry
Report an issue with this sectionGET /knowledge/manifest?after_id=0 returns up to 500 records with external_type, external_id, revision, deleted, hashes, and index_status. If next_after_id is non-null, fetch the next page. An optional external_type=order filters the list. This is a live manifest, not a frozen snapshot or “updated since” feed; repeat a full pass after writes settle. Do not permanently advance a cursor expecting old records' updates to appear later.
GET /knowledge?page=1 returns paginated full record data (50 per page). Treat this as sensitive server-side data. The manifest avoids returning content and is preferable for synchronization.
Delete with a higher revision and the explicit type in the JSON body:
curl --fail-with-body -X DELETE "$HUCK_API_URL/api/v1/knowledge/42" \
-H "Authorization: Bearer $HUCK_INGEST_KEY" \
-H 'Content-Type: application/json' \
--data '{"external_type":"order","revision":4}'
Deletion creates a tombstone even when a record has not arrived yet, removes its indexed chunks, and prevents an older delayed write from resurrecting it. Replaying the same delete revision is accepted. Restoring a tombstone requires a higher revision and content. URL-encode path IDs; use route-safe IDs such as UUIDs or alphanumeric identifiers for records you must delete/retry by path.
For a non-deleted record requiring a new indexing attempt, POST /knowledge/{externalId}/retry with {"external_type":"order"} returns 202. It does not increment the source revision. An active indexing attempt is reused; a new attempt still consumes the applicable ingestion allowance. Check the console or GET /operations/status using operations:read to diagnose a backlog.
Search as the actual user
Report an issue with this sectionWith a chat session minted for a stored identity and the exact approved Origin, call:
curl --fail-with-body "$HUCK_API_URL/api/v1/knowledge/search" \
-H "Authorization: Bearer $HUCK_CHAT_TOKEN" \
-H 'Origin: https://your-website.example' \
-H 'Content-Type: application/json' \
--data '{"question":"What is the status of my order?"}'
The response contains only authorized retrieval evidence. Search and chat share the authorization boundary; testing with a server integration key would not represent the user's view.
Current structural limits
Report an issue with this sectionThe schema is extensible; resource use is bounded. Current defaults are: 200,000 encoded bytes per content/metadata payload, depth 16, 10,000 JSON nodes, 100 relationships, 100,000 normalized characters, 400,000 normalized bytes, and 700,000 total stored bytes per record. A relationship name is bounded to 80 characters. Request bodies are limited to 2,000,000 bytes. Plan allowances additionally limit records, chunks, stored bytes, and ingestion tokens. Split large records at meaningful authorization boundaries; never split away the gates required for an individual chunk's source record.
Continue with identity and hierarchy access, evaluations using these records, or errors and retry decisions.