Your own repositorybeta

Customizations of the CRM live in a file ispis.yaml in your own git repository: buttons on cases and files (customActions), template variables filled from your systems (templateCustomVariables), your own case categories and states (categories), task kinds (taskVerbs), person kinds (personKinds), blocks on the case card rendered by your code (sections) and events delivered to it (events). Connect the repository in Settings → Custom: on GitHub install the iSpis app (one click), any other git works with a deploy key with write access and a webhook. iSpis clones the repository right away, validates every push before applying it and can commit changes you approve (for example proposed by your AI assistant) as iSpis <no-reply@sokordia.com>. Without a repository iSpis hosts the file for you – an AI assistant connected over MCP edits it with ispis_yaml_propose.

The JSON schema is at ispis-schema.json; validate locally with yajsv -s ispis-schema.json ispis.yaml. An invalid file is reported in Settings → Custom (and by e-mail): every push is validated before it is applied, and an invalid commit is not applied at all – the last valid version stays in use until you push a fixed one.

Example

service:
  url: https://crm.example.com/ispis   # only when the server runs on your side
  auth: a-shared-secret-of-your-choice # only with url; the hosted server gets ISPIS_AUTH from iSpis
  path: ispis/service.ts               # hosted module (default), relative to ispis.yaml

customActions:
  - id: TransferToCollection
    title: Transfer to external collection
    domain: case            # case | file | person | claim | paid | txn
    type: Remote            # Remote = your back-end, Core = built-in action
  - id: StatementHTML
    title: Statement as HTML
    button: HTML
    domain: file
    limitFileType: statement
    type: Remote

templateCustomVariables:
  - id: Loan                # used as [% Loan %] in templates

sections:                   # hosted server only (no service.url)
  - id: settlement          # GET /section/settlement?refno= returns HTML
    placement: detail
    right: modify           # case right needed to submit its forms (default)

events: [taskDone]          # hosted server only: POST /event/taskDone

categories:
  - id: Settlement
    title: Settlement
    default: true           # category of a new case (at most one)
    initialState: Received  # state of a new case
    states:                 # order of states = order in the list
      - id: Received
        desc: Received, waiting for analysis
        color: '#33B5E5'
        colorAlt: '#0099CC' # optional, a darker shade is derived
        substates: [YES, NO]
      - id: Negotiation
        desc: Proposal sent, waiting for the other party
        color: '#FFBB33'
      - id: Settled
        desc: Signed and paid
        color: '#666666'
        closed: true        # terminal state; at least one per category
      - id: Closed
        color: '#9E9E9E'
        closed: true
  - id: Inkaso              # a shared built-in category, referenced by id

taskVerbs:
  - { id: analyse, title: Analyse }
  - { id: refund, title: Refund }

personKinds: [client, representative, finarb]

service

The custom server that answers custom actions, template variables, case-card sections and events. Either we host it for you (leave url out and put ispis/service.ts in the repository, see below) or it runs on your side (url). iSpis calls fixed endpoints under the base URL and sends Authorization: Bearer <secret> (your service.auth with url; for the hosted server a secret iSpis generates and passes to it as ISPIS_AUTH), X-Ispis-Ak, X-Ispis-Login (the acting user) and X-Ispis-Request (id for your log) with every request:

WhatRequestResponse
custom action on a case or personGET <url>/action/<id>?refno=<RefNo>JSON
custom action on a fileGET <url>/action/<id>?peId=<file id>JSON
template variableGET <url>/var?name=<id>&refno=<RefNo>JSON
link on the case cardGET <url>/link?refno=<RefNo>{ "url", "title" } or {}
case-card section (hosted only)GET <url>/section/<id>?refno=<RefNo>HTML fragment, empty = hidden
form in a section (hosted only)POST <url>/section/<id>/<form>?refno=<RefNo>JSON
event (hosted only)POST <url>/event/<name>204

A custom action returns JSON; a download action returns { "name": "file.pdf", "base64": "..." }. A template variable returns JSON (a string or an object whose keys are used as [% Loan.Amount %]). Errors go to the x-ispis-error response header. An item may instead carry its own absolute url (legacy), then service is not needed and refno= / peId= is appended to it.

Hosted server (ispis/service.ts)

Put a Deno module in ispis/service.ts (another path via service.path, relative to ispis.yaml; the repository may hold your other things). We run it as a separate process with minimal permissions, start it on first use, stop it when idle and restart it after every push. Its log (stdout and stderr) is in Settings → Custom.

Runtime contract – read this before writing the module (also when an AI assistant writes it for you):

  • No TCP port, no PORT variable. The server must listen on the unix socket whose path is in ISPIS_SOCKET: Deno.serve({ path }, handler). iSpis waits up to 30 seconds after the start for GET /health to return 200; a module that does not listen there (or exits) fails to start.
  • Environment: only ISPIS_AK (your tenant code), ISPIS_URL (base URL of your CRM), ISPIS_SOCKET, ISPIS_AUTH (the secret), PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD (+ other PG* options of postgres.js), TZ, HOME and DENO_DIR can be read. Reading any other variable (PORT, DATABASE_URL, NODE_ENV, …) throws NotCapable – an uncaught one kills the process. Do not probe for others; there are none.
  • Files: read only inside your repository, write only to var/ and log/ in it (both are created for you and are not part of git). No /proc, no parent directories, no subprocesses.
  • Network: outgoing HTTP(S) anywhere (your webhooks, APIs), except the internal iSpis services.
  • Auth: every request except /health carries Authorization: Bearer <ISPIS_AUTH>; answer 401 when it does not match.
  • Code: the module is type-checked (deno check) before every start – a type error is reported in the log and the old version keeps running. npm: and jsr: imports work (npm:postgres@3.4.5 for the database); there is no SDK to import from your repository. Exit cleanly on SIGTERM (5 s, then it is killed). Limits: 512 MB memory, half a CPU.
  • Database: postgres.js with no arguments (postgres()) logs in from the PG* variables as your own role; row-level security shows and lets you change only your rows (read-write on cases, persons, claims, files, screenings and settings). Main tables: project = case (path is the RefNo, category, state, substate, labels, your own data in fields jsonb), person (first_name, last_name, company_name, company_no = IČO, isds = data box, email, phone, address_main), project_person (project → project.id, person → person.id, kind = role on the case), project_entry (case history: kind = task, note, file, sent, …; task_verb, task_finished, note_text), debt (claims, project_path = RefNo, amount_cents). Look up other columns in information_schema.columns; do not guess tables by column names.

A complete module to start from (events, a section with a form, a link):

import postgres from "npm:postgres@3.4.5"

const sql = postgres({ max: 2 })           // PG* from the environment
const AUTH = Deno.env.get("ISPIS_AUTH") ?? ""
const SOCKET = Deno.env.get("ISPIS_SOCKET")!
const esc = (s: unknown) => String(s ?? "").replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`)
const json = (body: unknown, status = 200, headers: HeadersInit = {}) => Response.json(body, { status, headers })

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url)
  const path = url.pathname
  if (path == "/health") return json({ ok: true })
  if (req.headers.get("authorization") != `Bearer ${AUTH}`) return new Response("Unauthorized", { status: 401 })
  const refno = url.searchParams.get("refno") ?? ""
  try {
    if (path == "/event/taskDone" && req.method == "POST") {
      const ev = await req.json()              // { refno, peId, verb, login }
      console.log("taskDone", ev)
      return new Response(null, { status: 204 })
    }
    if (path == "/section/sync") {             // sections: [{ id: sync, placement: detail }]
      const [c] = await sql`select path, category, state from project where path = ${refno}`
      if (!c) return new Response("")          // empty = section hidden
      return new Response(`<div class="card mb-3">
        <div class="card-header">Our application</div>
        <div class="card-body">
          <p class="card-text">Case ${esc(c.path)}: ${esc(c.state)}</p>
          <form data-api="/pripad/section/sync/${esc(refno)}/send">
            <button type="submit" class="btn btn-primary btn-sm">Send</button></form>
        </div></div>`, { headers: { "content-type": "text/html; charset=utf-8" } })
    }
    if (path == "/section/sync/send" && req.method == "POST") {
      const persons = await sql`select p.first_name, p.last_name, p.company_name, p.company_no, pp.kind
        from project c join project_person pp on pp.project = c.id join person p on p.id = pp.person
        where c.path = ${refno}`
      return json({ ok: true, persons: persons.length })
    }
    if (path == "/link") return json({ url: "https://example.com/case/" + refno, title: "Open in our app" })
    return new Response("Not found", { status: 404 })
  } catch (e) {
    console.error(path, e)
    const msg = e instanceof Error ? e.message : String(e)
    // shown to the user; header values must be ASCII
    return json({ error: msg }, 500, { "x-ispis-error": msg.replace(/[^\x20-\x7e]/g, "?").slice(0, 500) })
  }
}

try { Deno.removeSync(SOCKET) } catch { /* not there */ }
const server = Deno.serve({ path: SOCKET, onListen: () => console.log("listening on", SOCKET) }, handler)
Deno.addSignalListener("SIGTERM", async () => { await server.shutdown(); await sql.end(); Deno.exit(0) })

A section (id, placement: detail, right) returns an HTML fragment that is shown on the case card without any JavaScript; right is the case right a user needs to submit its forms (view, modify (default), solve, file, note, payments, send, sensitive, finance, invoices): dialogs are <dialog data-ui-modal> opened by <button commandfor=ID command="show-modal">, forms are <form data-api="/pripad/section/<id>/<RefNo>/<form>"> whose fields are posted as JSON to your POST /section/<id>/<form>; after success the section is re-rendered. An empty response hides the section, so decide inside your code for which categories or states it applies.

HTML and styling: the CRM uses Bootstrap 5 – style everything with Bootstrap classes, no <style>, no inline colours, no own CSS framework, no <script> (it is not run). Wrap a case-card section in a card:

<div class="card mb-3">
  <div class="card-header">Our application</div>
  <div class="card-body">
    <p class="card-text text-muted">Last sync 2026-11-11 10:46: 4 cases.</p>
    <form data-api="/pripad/section/sync/48/send">
      <button type="submit" class="btn btn-primary btn-sm">Send</button>
    </form>
  </div>
</div>

Inside use the usual components: btn btn-primary / btn-outline-secondary (btn-sm on the card), form-control, form-select, form-label, mb-3, table table-sm, list-group, badge, alert alert-danger, text-muted, d-flex gap-2, row/col-*; dialogs <dialog data-ui-modal> with modal-header / modal-body / modal-footer. Escape every value you put into the HTML.

Events: with events: [taskDone] we POST /event/taskDone with { "refno", "peId", "verb", "login" } whenever a task on a case is finished; change the case yourself through sql (for example move it to a closed state).

categories

Replaces the built-in categories of your account with your own. A category has an id (letters, digits, _), an optional title, default: true for the category of a new case, initialState for the state of a new case and a non-empty ordered list of states. An item with only an id refers to a shared built-in category (Inkaso, Advokat, Marketing, Loan, Collection, Attorney), so you can combine your own categories with ours.

A state has id, optional title and desc, color (#rrggbb), optional colorAlt (darker shade), optional substates and closed: true for a terminal state. Closed cases are excluded from active lists and from "not closed" exports; Close case switches to the first closed state (Closed, ExtClosed or Uzavřeno preferred), so give every category at least one. States already used by your cases must stay in the list, otherwise such cases show without a state badge.

taskVerbs, personKinds

  • taskVerbs replaces the built-in task kinds; title is the English label (translated where we have a translation), default is the built-in label or the id.
  • personKinds restricts the person kinds offered on a case to a selection of the built-in ones (client, representative, finarb, debtor, …).
  • The system login (the user that authors automatic changes, for example a state change after a finished task) is set by us on activation.

Rules

  • Custom data on a case or person is key → value (string). A structured value goes under a key with the suffix JSON, stored as a JSON string.
  • config.yaml in the same repository belongs to the Loan module; once ispis.yaml exists, CRM keys in config.yaml are ignored.
  • Outgoing webhooks (case created, screening finished, inbox message) and further events are available after activation.

Source: src/core/crm/crm.docs.md