> ## Documentation Index
> Fetch the complete documentation index at: https://docs.instaview.sk/llms.txt
> Use this file to discover all available pages before exploring further.

# September 2026

> Changelog entries for September 2026

## September 9, 2026

### Changed

* **Contact context stopped being a curation chore.** `contextConfig.useContactContext` used to
  volunteer a summary built from the fields you had ticked as contact context, and everything
  else the contact held was invisible to the agent. It now gives the agent two things: a short
  platform-decided set of fields in its **prompt**, and a generated **document it can query
  mid-call** holding everything else the contact has a value for. Their documents (a CV, files
  you uploaded) are on the same knowledge base as before.

  Nothing about the agent switch changed, and no request shape you send changed. What changed is
  how much reaches the agent, and it is strictly more.

* **`includeInContactContext` is removed from `/contact-fields`.** It was a per-field allowlist
  that shipped empty, so the feature was effectively off for anyone who had not walked the
  catalog. Two properties replace it, and neither needs curating:

  ```javascript theme={null}
  GET /contact-fields
  {
    "key": "amount_due",
    "isPii": true,
    "allowPiiInContext": false,   // yours to set, and only on a PII field
    "inEssentialPromptSet": false // ours to decide, read-only
  }
  ```

  **Take it out of your payloads.** Sending `includeInContactContext` on a create or update is
  now a `400`, as any unknown property is:

  ```text theme={null}
  Validation failed: Property 'includeInContactContext' should not exist
  ```

  Beyond that, if you were setting it on a **non-personal** field you need do nothing: that
  field now reaches the agent by default, through the document. If you were setting it on a
  field marked `isPii`, that decision was migrated to `allowPiiInContext` and still holds.

  "By default" is not "always". A field reaches the agent when it is not `readOnly` and either
  is not `isPii` or has been let past that: `company_name`, `company_description`, `agent_name`
  and `profile` are `readOnly`, describe the **caller** rather than the person being called,
  and are in neither the prompt nor the document whatever you set on them.

* **`isPii` now enforces rather than defaults.** A personal field is excluded from the prompt
  AND from the document, which is what keeps `email` and `phone` off every call without anyone
  deciding. `allowPiiInContext` is the way past it, per field, and it puts the value in both
  halves — a queryable value is one the agent might never think to look up, and for something
  like an amount owed that is the whole point. Sending `allowPiiInContext` on a field that is not
  `isPii` is a `400` rather than a silent no-op.

### Notes

* A contact's document is generated the first time a context-enabled agent is **scheduled** to
  call them, and refreshed before a later call only when a value or the catalog actually changed.
  A contact nobody calls costs nothing. An immediate ad-hoc call for a contact with no document
  yet runs prompt-only rather than waiting on one, and it is ready for the next call.
* A value written between generation and the dial is not in that call's document. The window is
  bounded by the freshness check at dial time.
* The document follows the contact's retention, exactly as their uploaded documents do: it is
  deleted, provider copy included, when the contact is deleted or reaches its GDPR expiry.
* It does not appear in `GET /contacts/{id}/documents` and does not count against the five
  uploads per contact. It is our projection of data you already hold, not a file you attached.

## September 8, 2026

### Behavior Changes

* **A conditional branch's `condition` is now validated.** It never was. A branch could be stored with a condition the platform could not read — a `type` outside `intent` and `expression`, an `intent` with no `description`, an `expression` with no `field` — and the flow was accepted. Nothing told you, and the agent still took that branch on calls: the compiled instruction for it came out describing nothing, so the route was chosen arbitrarily. That is the failure this closes.

  Four new codes join the `errors` array on [Create Agent](/api-reference/agents/create-agent) and [Update Agent](/api-reference/agents/update-agent), each with the `path` of the offending branch:

  | Code                                 | Raised when                                                                                                                                                                                                  |
  | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
  | `CONDITIONAL_CONDITION_MISSING`      | `condition` is absent, `null`, or not an object                                                                                                                                                              |
  | `CONDITIONAL_CONDITION_TYPE_UNKNOWN` | `type` is neither `intent` nor `expression`                                                                                                                                                                  |
  | `CONDITIONAL_CONDITION_INVALID`      | the type is known but incomplete: an `intent` with a blank `description`, or an `expression` with a blank `field`, an operator outside the documented set, or no `value` for an operator other than `EXISTS` |
  | `CONDITIONAL_BRANCH_NOT_AN_OBJECT`   | an entry in `branches` is not an object at all                                                                                                                                                               |

  That last one closes a hole of its own. A `null` in `branches` counted toward the branch total and then every per-branch rule skipped it, so the flow validated clean and the failure surfaced later as a `500` while the prompt was being built. It is a `422` naming the branch now.

  ```json theme={null}
  {
    "code": "CONDITIONAL_CONDITION_TYPE_UNKNOWN",
    "message": "Unknown branch condition type: variable_exists. Must be one of: intent, expression.",
    "path": "sections[0].branches[1].condition"
  }
  ```

  **What to check in your own integration.** A payload that sent a condition of your own shape, or left one off, was accepted before and is a `422` now. Everything the API has ever documented for `FlowBranchCondition` still passes unchanged, `EXISTS` still needs no `value`, and `logicalOperator` is still accepted and ignored. The branch you nominate as `defaultBranch` is checked like the rest: its condition is not read while it is the fallthrough, but moving `defaultBranch` later would put it back in charge of a route.

  Agents already holding an unreadable condition were repaired rather than left to fail: each one became `{ "type": "intent", "description": "<the branch's label>" }`, the label being the only authored statement of what that route means. Their cached prompts were dropped so the next call compiles from the repaired flow. If a repaired branch does not route the way you meant, the label is what to rewrite.

## September 7, 2026

### Added

* **Contact documents**: a contact can now hold documents an agent may look up on a call — a cover letter, application notes, and the contact's own processed CV. Five endpoints under `/contacts/{id}/documents`, mirroring the agent knowledge base exactly: list, start an upload (presigned URL), complete it, rename or deactivate, delete. Same request and response shapes, same file types, same 20 MiB per-file limit, same upload rate limit. Scopes are `read:contacts`, `write:contacts` and `delete:contacts`.

  ```javascript theme={null}
  GET    /contacts/{id}/documents
  POST   /contacts/{id}/documents/uploads          // presigned ticket
  POST   /contacts/{id}/documents/{docId}/complete
  PATCH  /contacts/{id}/documents/{docId}          // title, isActive
  DELETE /contacts/{id}/documents/{docId}
  ```

  **A contact's CV shows up on its own.** Once a CV has been processed, the contact lists a document with `source: "CV"`. You did not upload it and cannot delete or rename it here — it is produced from the anonymized text of the CV, which is what the agent can look up. You can deactivate it, and that is how you keep a particular contact's CV out of calls.

  **Two limits instead of one.** A contact holds at most **5** uploaded documents (the CV does not count). On top of that, your company's total contact-document storage is capped by plan, and both are **reserved** when the upload is started — pending uploads included — so parallel requests cannot all pass the same check. When the company ceiling is the problem, the `400` says so in numbers:

  ```text theme={null}
  Contact document storage limit reached for this company: 248MB of 250MB in use, and this file needs 3MB more.
  ```

* **`source` on knowledge documents**: every document — agent or contact — now carries `source`, either `"UPLOAD"` or `"CV"`. On an agent's knowledge base it is always `"UPLOAD"`. Additive; nothing else about the shape changed.

* **Whether documents reach a call is not decided by the API.** One switch decides: the agent must have `contextConfig.useContactContext` enabled. With it on, the contact's active documents are attached to the call as the `candidate` knowledge base and the agent is told they exist and may be looked up, never read aloud. With it off, the call runs exactly as before. Every other knowledge base (`company`, `position`, `reference_documents`) is unaffected.

  Contact documents follow the contact's retention: they are deleted — stored files and provider copies included — when the contact reaches its GDPR expiry date.

* **The contact-field catalog now ships thirty-six default fields instead of eleven**: `GET /contact-fields` returns a seeded set covering identity, the contact's employer, orders and invoices, the relationship, appointments and screening, so most of what you would have defined by hand already exists.

  Only three of the previous eleven (`order_number`, `is_vip`, `lead_source`) could hold a per-contact value at all; the rest resolve from the contact record, your company or the agent. The new rows are the ones an agent would actually say: `job_title`, `organization`, `department`, `company_size`, `industry`, `website`, `city`, `secondary_phone`, `invoice_number`, `amount_due`, `due_date`, `renewal_date`, `contract_end_date`, `plan_name`, `deal_value`, `lead_status`, `owner_name`, `customer_since`, `last_purchase`, `tags`, `appointment_at`, `years_experience`, `notice_period`, `salary_expectation` and `availability_date`.

  Nothing breaks — the response is a list and you iterate it — but two things follow. **If you render every field you get back as a form row, expect thirty-six rows.** And **most** seeded fields are still yours to shadow: define your own field with the same key and it overrides the label and type for your company alone. That is the intended path for `company_size`, `industry` and `lead_status`, which are seeded as `string` because one company's option sets are not another's. The eight reserved keys (`first_name`, `last_name`, `email`, `phone`, `company_name`, `company_description`, `agent_name`, `profile`) refuse even that, because the platform resolves them itself.

  Five of them default to `includeInContactContext`, so an agent with `contextConfig.useContactContext` on can refer to them without a token in the flow: `lead_source`, `job_title`, `plan_name`, `owner_name` and `last_purchase`. Everything else is off, because every field that defaults on lengthens every prompt on every call.

  <Note>
    Superseded on September 9: `includeInContactContext` is gone and there is no per-field
    opt-in any more. See that day's entry.
  </Note>

  <Info>
    **`organization` is the contact's employer; `company_name` is yours.** `{{contact.company_name}}` resolves the company **placing** the call and always has — it is labelled "Your company name" for that reason. An author writing *"I see you work at `{{contact.company_name}}`"* got a fluent wrong sentence with no signal, so `organization` now exists for what that author meant.
  </Info>

* **`category` on a contact field**: a new response property on `GET /contact-fields` naming which panel the field belongs to — `identity`, `organisation`, `commercial`, `relationship`, `scheduling`, `recruiting`, `platform` or `custom`.

  The list is now ordered by `category` first, then `displayOrder`, then `key`, so iterating the response and breaking on a change of `category` gives you the same panels the dashboard shows. It is set by InstaView and not writable: a field you define is always `custom`, and a field of yours that shadows a seeded one reports the **seeded** field's category, so `job_title` stays under `organisation` for everybody even after you relabel it.

  Because `displayOrder` now only separates fields inside one category, each category's numbering restarts at 10 rather than continuing one long sequence. If you were sorting on `displayOrder` alone, sort on `category` first.

* **A `tags` data type**: `dataType: "tags"` stores a `string[]`, and it is the one type that accepts two shapes on the way in — an array, **or** one comma-separated string, so a single CSV cell holding several tags maps onto one field without your side splitting it first.

  ```javascript theme={null}
  PATCH /contacts/{id}
  { "fields": { "tags": ["vip", "renewal"] } }

  // Same result
  PATCH /contacts/{id}
  { "fields": { "tags": "vip, renewal" } }
  ```

  Entries are trimmed, blanks are dropped (`"vip,, renewal,"` is two tags) and duplicates are removed case-insensitively, keeping the spelling that came first. An empty list is the erase: `[]`, `[""]` and `","` all clear the field, exactly as `null` and `""` do, so you never get a key present with an empty array behind it. A number, a boolean or an object is a `422` rather than a one-element list — a caller sending `42` for a tags field has a mapping bug, and a silent `["42"]` would be spoken on a call.

  The seeded `tags` field uses it. `enum` is still the right type when the set is closed and a value outside it should be refused.

* **CSV aliases on every writable seeded field**, in English, Slovak and Czech — `renewal_date` carries `renewal`, `renews` and `dátum obnovenia`; `amount_due` carries `balance`, `outstanding` and `dlžná suma`.

  **Nothing consumes them yet.** The current CSV importer maps against a fixed list of built-in contact properties and does not read `csvAliases`, so this changes no import behaviour today — it is catalog metadata, seeded so the catalog is complete before the contact-field import lands. Adding your own with `PATCH /contact-fields/{id}` does not touch InstaView's.

* **`fields` on the contact resource itself**: `POST /contacts` and `PATCH /contacts/{id}` now accept catalogued field values in the body, so creating a contact with its values is one request instead of two.

  ```javascript theme={null}
  POST /contacts
  {
    "firstName": "Jane", "lastName": "Doe", "email": "jane@example.com",
    "fields": { "is_vip": true, "order_number": "SO-40128", "renewal_date": "2026-04-01" }
  }

  // Merges: a key you omit keeps its value, an explicit null clears one
  PATCH /contacts/{id}
  { "fields": { "order_number": "SO-40129", "renewal_date": null } }
  ```

  Before this, the read of a contact's values sat on the contact (`GET /contacts/{id}` returns `fields`) while the write sat somewhere else — and `POST /contacts` dropped any inbound values and always answered `fields: {}`, so the one request where you most want to set them was the one that could not. Field values are contact attributes; the resource for a contact's attributes is the contact.

  The shape is the flat one `GET /contacts/{id}` already returns, so read and write agree. **`fields` merges and `metadata` replaces**, in the same request body, on purpose: `metadata` is one opaque document you own, `fields` are individually catalogued values.

  <Warning>
    **A rejected value takes the whole request with it.** Values are validated against the catalog inside the same transaction as the contact write, so an unknown key or a wrong-typed value answers `422` naming it and nothing is applied — not the contact, not the status, not the other values. A contact that exists without the values you sent is a half-write you cannot detect without re-reading.
  </Warning>

* **The `/contacts/{id}/fields` sub-resource is gone.** `GET` returned a strict subset of `GET /contacts/{id}`, and its write did exactly what `PATCH /contacts/{id}` with only `fields` does — same scopes, same guards, same blast radius, since a partial update touches only what you send. One resource, one shape.

  `?createMissingFields=true` moved with it and now hangs off both contact writes. Off by default: a key in `fields` that your catalog does not know is a `422` unless you ask for it to be defined, because a typo would otherwise mint a permanent field visible in every agent's variable palette.

### Behavior Changes

* **`metadata` no longer has a reserved key.** `metadata.contactVariables` used to be cut out of what you sent and cut out of what you were shown, because a contact's field values were stored inside that same object. They are stored separately now, so `metadata` is once again exactly the document you put there, `contactVariables` included.

  ```javascript theme={null}
  PATCH /contacts/{id}
  { "metadata": { "contactVariables": { "note": "mine" } } }

  // ...comes back verbatim, and reaches no agent
  {
    "metadata": { "contactVariables": { "note": "mine" } },
    "fields": { "order_number": "SO-40128" }
  }
  ```

  Nothing about `fields` changes, in either direction. A value still reaches `{{contact.<key>}}` only by being sent as `fields` and passing catalog validation, and replacing `metadata` still cannot disturb the values you have stored — that guarantee is stronger now, not weaker, because the two are no longer the same column. If you were avoiding the key to work around the old reservation, you no longer need to.

***

## September 3, 2026

### Improvements

* **`idealAnswer` is now optional on a flow question**: a `looping` question needs only its `question` text. `POST /agents` and `PATCH /agents/{id}` no longer answer `422 LOOPING_QUESTION_INCOMPLETE` for a question that omits `idealAnswer`, which makes a question set with no right answers, an internal survey or a satisfaction check, expressible for the first time.

  ```javascript theme={null}
  {
    "type": "looping",
    "questions": [
      { "question": "How has the rollout felt so far?" },
      { "question": "Anything you would change about the process?" }
    ]
  }
  ```

  `idealAnswer` was only ever scoring metadata: it is not spoken, it never reaches the agent's instructions, and scoring has always skipped a question that has none. Requiring it therefore forced callers to invent an ideal answer for a question that has none, and that invented value then decided whether the question looked scoreable.

  Nothing changes for a question that sends one, and the scoring rules are unchanged: `importance` or `weight` opts a question into the match score, and a question with no `idealAnswer` cannot be scored at all, so setting `importance` on one has no effect. The same relaxation applies in the visual builder, where the ideal answer field can now be left blank.

## September 2, 2026

### Improvements

* **Owners invited through the API can now set a password**: creating or updating a company with `ownerEmail` still sends the invitation email, and for an address that has no InstaView account yet it now also creates the login identity and sends a separate **set your password** email from Auth0.

  Before this, the invitation was the only thing the new owner received. Its link leads to a page that asks them to sign in, and having never signed up anywhere they naturally reached for "Reset password" — which for an address with no account is answered with a success message and no email, deliberately, so that the reset form cannot be used to find out which addresses have accounts. The invitation was valid the whole time and there was no way to tell.

  Nothing changes in the request or the response, and nothing changes for an `ownerEmail` that already belongs to an InstaView user: they are assigned as `OWNER` as before, with no extra email. An address that already has a login but no company gets the invitation only, not a password email it did not ask for.

* **The invitation page opens without signing in first**: `/invite/accept` now renders for a visitor with no session, shows which email address the invitation was issued for, and leads with **Create an account and accept**. Signing in is the second option, for people who already have one.

  Previously the link redirected straight to the sign-in form, so an invitee with no account saw neither the invitation nor a way to create one. Signing up with the invited address accepts the invitation automatically once the email is verified.
