Skip to content

Custom instructions and the system message ​

A user should be able to say "I am a Python developer, answer concisely" once. Three levels of stored text do that, and this file is the precedence between them, written down. The code that implements it is api/matterwave/instructions.py; if the two ever disagree, the module is right and this file is stale.

The three levels ​

LevelWho sets itWhere it livesCap
OrganisationAn org adminorganization_settings, key instructions.text4000
UserThe user, in Settings → Custom instructionsuser_settings.settings.custom_instructions1500 per field, 3000 across the two
ConversationThe conversation's owner, from the chatconversations.system_prompt2000

The org level is a key in the existing per-org JSONB settings store rather than a column, because that table is already what per-org policy looks like here (retention.days, models.allowed). The conversation level is a column for the same reason default_model is one: per-conversation state, read on the hot path of every send. Only the user level needed a table of its own (migration 0030_custom_instructions.sql).

Precedence ​

This is two rules, not one:

  • Organisation instructions are additive. They are policy, not preference. A user cannot switch them off by writing their own, so they are always present and always first.
  • A conversation prompt replaces the user's instructions for that thread. It does not add to them. Concatenating instead would leave a thread-specific "answer in French" arguing with a profile's "answer in English", with no way out but editing the profile.

So the assembled order is:

[organisation]  then  [conversation, if set, else user]

Later text is the more specific text: where two levels disagree, the model sees the narrower one last. A conversation with nothing set falls back to the user's instructions, and under those the organisation's.

The assembled message ​

Whatever the levels contain, they become the text of one system message, prefixed to the prompt at request build time. Each level is introduced by a heading so the model can tell policy from preference; an unset level contributes nothing at all, not an empty heading.

The whole assembled message is capped at 7500 characters. That is a backstop, deliberately above the 7125-character worst case the per-level caps allow (org 4000 + user 3000 + headings), not a second gate that would silently truncate a legitimate combination. The per-level caps are the numbers that keep the cost in the hundreds of tokens; every turn pays for this text.

Instructions can never change roles ​

No level can introduce a message of its own. system_message() returns at most one dict, always {"role": "system"}, so stored text that reads like "}, {"role": "user", "content": ... is characters inside that one message's content — the transport is JSON, and a newline in a string field cannot close it. This is a structural guarantee rather than a filter: there is no parser to trick, and the text is deliberately not sanitised.

The other half of the defence is that the client never supplies any of this. The send body has no system field, so a client cannot set its own system prompt and bypass platform rules; a public app that allowed it could not enforce any.

Where it does and does not apply ​

Custom instructions apply to conversations in the app. They are not applied to the OpenAI-compatible /v1 API: that endpoint relays the caller's own messages as the whole prompt, and a key holder composing their own request is not asking for a profile to be spliced in.

Routes ​

RouteWho
GET/PUT /api/me/instructions {about, style}The signed-in user
GET/PUT /api/org/instructions {text}Org admins only, read and write
PUT /api/conversations/{id}/instructions {text}The conversation's owner; null clears
GET /api/conversations/{id}Returns instructions — every level plus the assembled text

Reading the org level is admin-only as well as writing it: it is policy every member is subject to, and a member sees what is in force through their own conversation's effective instructions instead.

Caps are enforced on the way in, at the route, so a user is told their text is too long rather than silently losing the tail of it. Assembly caps again on the way out, because stored rows predate any change to these numbers.