EVERJUST Sign — Agent Skill
Operate the Sign app of an everjust.app tenant as a running agent: turn a PDF into
a signature request, attach signers by role, send it, track signing progress, pull the
finished signed PDF, and read the audit/integrity trail — all through the Odoo MCP / ORM
(search, get, create, update, call a method). See [[everjust-agent-mcp]] for how
to open a session against the right tenant (https://<tenant>.everjust.app/mcp, Bearer =
Odoo API key, runs AS that user, every call audited).
This is sign_oca — the community OCA "Sign Oca" addon (author Dixmit/OCA,
19.0.1.0.0, AGPL-3), rebranded only in strings to "EVERJUST.APP". It is a different
product from Odoo Enterprise Sign. There is no sign.request / sign.item here;
the models are sign.oca.*. Stock OCA behavior, no everjust fork of the module itself
(see Pitfalls for the platform-level gotchas that DO apply).
When to use this skill
- Send a document out for signature — from a one-off PDF or from a reusable template.
- Check a request's status — who has signed, who is pending, is it fully signed / cancelled.
- Find pending signers — the queue of signatures a partner (or the tenant) still owes.
- Generate requests from a template — one, or in bulk over a set of business records
(each request linked back to its record via
record_ref).
- Download the finished signed PDF and verify its tamper-evidence (per-signer
inalterable hash chain).
Do NOT use this skill for, and stop if the task is really:
- Odoo Enterprise Sign (
sign.request, sign.item, sign.template) or DocuSign /
external e-sign — this tenant runs sign_oca only; those models do not exist here.
- Making the invite email actually deliver — the invite is an Odoo
message_notify
(chatter notification). Whether it lands is a mail-transport question owned by
[[everjust-mail-ops]] (verified sending identity, suppression, rate-gate). This skill
fires the invite; that skill tells you if it went out.
- SMS / OTP signer authentication —
sign_oca has no SMS and no OTP (OTP is on the
module ROADMAP, unbuilt). Do not expect a text to the signer; see [[everjust-sms]] if you
separately want to text them a heads-up.
- Registrar / DNS / product work on connectdomain — unrelated; that is [[godaddy-api]].
Where it's installed
Live introspected on the shared everjust box (one Postgres DB per tenant). sign_oca is
installed on: connectdomain, burekraft-llc, headsup, riftline-labs,
tcstartupweek, trust-works-company, weldon. Not present on control. Confirm the
tenant before operating; everything is per-company_id (see Pitfalls). On a fresh tenant
the seed data ships 6 fields (Name, Email, Phone, Text, Signature, Check) and 2 roles
(Customer, Employee, both partner_selection_policy='empty') — verified on connectdomain.
The model map
Three core models plus a field-catalog, a role catalog, a template-item child, and an
append-only log. All the request/signer models inherit mail.thread + mail.activity.mixin
(so they have chatter and activities — usable, but the signing audit trail is the
dedicated sign.oca.request.log, not chatter).
| Model |
Role |
Key fields (real, from source + live fields_get) |
sign.oca.template |
Reusable blueprint: a PDF + placed fields + roles. Optionally bound to a business model. |
name (req), data (binary PDF, req, attachment=True), filename, item_ids (→ sign.oca.template.item), model_id (→ ir.model) / model (computed char), ask_location, active, request_count, request_ids |
sign.oca.template.item |
One placed field on a template page (position is % of page, 0–100). |
template_id (req), field_id (→ sign.oca.field), role_id (→ sign.oca.role, default = Customer), required, page (req, default 1), position_x/position_y (req, %), width/height (%), placeholder |
sign.oca.request |
A live signature request over one PDF. This is the thing you send and track. |
name (req), data (binary PDF, req — mutated in place as signers sign), template_id, state (req: 1_draft→0_sent→2_signed/3_cancel), signed (bool), signer_ids (→ signer), signer_id (computed: the signer matching the current user), signatory_data (JSON — the placed fields), to_sign/signed_count/signer_count (computed), current_hash, record_ref (Reference → the linked business record), user_id (Responsible, req), company_id (req), ask_location |
sign.oca.request.signer |
One signer's slot on a request: who, in what role, with token + signed state + integrity hash. |
request_id (req), partner_id (→ res.partner, req), role_id (→ sign.oca.role, req), signed_on (datetime — None until they sign), signature_hash, access_token/access_url (portal link, from portal.mixin), is_allow_signature (computed: can the current user sign this now), secure_sequence_number + inalterable_hash + altered_hash (the tamper chain), latitude/longitude, data (related → request PDF), model/res_id (mirror of the linked record) |
sign.oca.field |
Catalog of field TYPES you can place. Seeded, tenant-wide (no company scope). |
name, field_type (text|signature|check), default_value (a partner attr name like name/email/phone, or False) |
sign.oca.role |
Catalog of signer roles + how to auto-resolve the partner when generating from a template. |
name (req), partner_selection_policy (empty|default|expression, req), default_partner_id, expression_partner (e.g. {{object.partner_id.id}}), domain (req) |
sign.oca.request.log |
Append-only audit trail (_log_access=False). Every view/configure/sign/cancel with actor IP. This is the legal trail, not chatter. |
request_id, signer_id, action (create/validate/view/sign/add_field/edit_field/delete_field/cancel/configure), uid, partner_id, date, ip, access_token |
The lifecycle (state machine)
create (1_draft) ──action_send()──▶ 0_sent ──all signers signed──▶ 2_signed
│ (auto, via _check_signed)
└──cancel()──▶ 3_cancel
1_draft — created, fields placeable/editable (add_item/set_item_data/delete_item
only work in draft, and require ≥1 signer). Not yet visible to signers.
0_sent — action_send() was called: it stamps a validate log, sets state, mints a
portal token per signer, and fires the invite message_notify to each signer's partner.
Signers can now sign at their access_url.
2_signed — reached automatically when the last signer signs (_check_signed
flips it once every signer_ids.signed_on is set). Do not set it by hand.
3_cancel — via cancel().
How signing actually mutates the PDF (don't fake it)
signer.action_sign(items, ...) (called by the public portal controller, not you) re-renders
the request.data PDF in place — burning each field's value (text / signature image /
check-cross) at its position_x/y (% of page), recomputes current_hash (SHA-1 of the new
PDF), sets the signer's signed_on + signature_hash, then extends the inalterable hash
chain (secure_sequence_number + inalterable_hash, chained off the previous signer via a
no-gap ir.sequence). This is why you never write data/signed_on/signatory_data by
hand — see Pitfalls.
Recipes
Route each through the Odoo MCP (call/create/update/search/get on the tenant).
env[...] shown for clarity; over MCP these are the generic tools operating on the model.
1. Create a one-off request from a raw PDF and send it
import base64
pdf_b64 = base64.b64encode(open("nda.pdf","rb").read()).decode()
# a partner to sign as (must exist; create/find via res.partner)
partner = env["res.partner"].search([("email","=","alice@example.com")], limit=1)
customer_role = env["sign.oca.role"].search([("name","=","Customer")], limit=1)
req = env["sign.oca.request"].create({
"name": "NDA — Alice",
"data": pdf_b64, # required; base64 PDF
"filename": "nda.pdf",
"signer_ids": [(0, 0, {
"partner_id": partner.id,
"role_id": customer_role.id,
})],
})
# req.state == "1_draft" now. Place fields if needed (recipe 3), then send:
req.action_send(message="Please sign the attached NDA.")
# -> state="0_sent", tokens minted, invite message_notify fired to each signer.
action_send returns nothing meaningful; verify by re-reading state and the signer's
access_url. The invite is a chatter notification — its delivery is a mail-transport
concern ([[everjust-mail-ops]]): a request can be 0_sent while the email never left (blocked
sending identity, suppression, rate-gate). If a signer says they got no email, hand them the
access_url directly.
2. Track status — who has signed, who is pending
req = env["sign.oca.request"].browse(req_id)
req.read(["name","state","signed","signer_count","signed_count"])
# Per-signer detail: signed_on is None until they sign.
env["sign.oca.request.signer"].search_read(
[("request_id","=",req.id)],
["partner_name","role_id","signed_on","access_url","secure_sequence_number"])
state == "2_signed" (or signed == True) is the only reliable "done" signal — it flips
automatically when the last signer signs. signed_count == signer_count says the same.
A signer with signed_on == False is still pending.
3. Find everyone's pending signatures (the queue)
# Pending signatures owed by a specific partner (across all requests):
env["sign.oca.request.signer"].search_read(
[("request_id.state","=","0_sent"),
("partner_id","=",partner.id),
("signed_on","=",False)],
["request_id","role_id","access_url"])
# Tenant-wide "what's out for signature but not done":
env["sign.oca.request"].search_read(
[("state","=","0_sent")],
["name","signer_count","signed_count","user_id"], order="create_date desc")
The web systray count comes from res.users.sign_oca_request_user_count() — you can call
it, but for an agent the explicit search_read above is clearer.
4. Generate a request from a template, resolving signers by role
tmpl = env["sign.oca.template"].search([("name","=","Employee Contract")], limit=1)
tmpl.read(["name","model","request_count"])
# Which roles the template needs and how each resolves its partner:
env["sign.oca.role"].browse(tmpl.item_ids.mapped("role_id").ids).read(
["name","partner_selection_policy","default_partner_id","expression_partner"])
# Preferred path — the wizard builds signatory_data + signers correctly:
wiz = env["sign.oca.template.generate"].with_context(default_template_id=tmpl.id).create({
"template_id": tmpl.id,
"signer_ids": [(0, 0, {"role_id": role_id, "partner_id": partner_id})
for (role_id, partner_id) in signer_pairs],
"message": "<p>Please sign.</p>",
})
wiz.generate() # creates the sign.oca.request, action_send()s it, returns the sign action
# Bulk over N business records (each request linked via record_ref):
env["sign.oca.template.generate.multi"].with_context(
model="res.partner", active_ids=[p1,p2,p3]
).create({"template_id": tmpl.id, "message": "<p>Sign</p>"}).generate()
Prefer the wizards (sign.oca.template.generate / .multi) over hand-building a request:
they call template._get_signatory_data() to snapshot the placed fields into the request's
signatory_data JSON and set signers from roles. Hand-building means you must replicate that
JSON yourself (see Pitfall 4). For a template bound to a model_id, the multi wizard is the
"Sign from template" server action; each resulting request carries record_ref back to its
source record.
5. Download the finished signed PDF + verify integrity
req = env["sign.oca.request"].browse(req_id)
assert req.state == "2_signed"
pdf_bytes = base64.b64decode(req.data) # the burned-in, fully-signed PDF
open("signed.pdf","wb").write(pdf_bytes)
# Tamper-evidence: every signed signer has a chained hash; altered_hash must be False.
env["sign.oca.request.signer"].search_read(
[("request_id","=",req.id), ("signed_on","!=",False)],
["partner_name","signed_on","signature_hash",
"secure_sequence_number","inalterable_hash","altered_hash","latitude","longitude"])
# Full legal audit trail (who did what, from which IP):
env["sign.oca.request.log"].search_read(
[("request_id","=",req.id)],
["date","action","uid","partner_id","ip","signer_id"], order="date")
altered_hash == True on any signer means the chained hash no longer matches — treat the
document as tampered. The sign.oca.request.log is the append-only trail (_log_access=False,
IP-stamped); it is the evidence record, not the chatter on the request.
6. Cancel a request
env["sign.oca.request"].browse(req_id).cancel() # -> state "3_cancel", logs "cancel"
There is no un-cancel and no built-in re-send of a cancelled request; generate a fresh one.
Pitfalls
This is OCA sign_oca, NOT Odoo Enterprise Sign. Reach for sign.oca.request /
sign.oca.request.signer / sign.oca.template — sign.request, sign.item,
sign.template do not exist on these tenants. Don't import Enterprise-Sign assumptions
(roles, tags, send-request wizard) — the API is the one documented here.
The invite email is chatter, and it can silently not deliver. action_send fires
mail.thread.message_notify per signer — an Odoo notification, subject to this
platform's mail send-gates (verified sending identity, suppression, hourly cap — see
[[everjust-mail-ops]]). A request sitting in 0_sent is not proof the signer was
emailed. If in doubt, read the signer's access_url and deliver the link yourself.
There is no SMS/OTP path (unbuilt — ROADMAP); don't promise the signer a text.
Never hand-mutate data, signed_on, signatory_data, state='2_signed', or the
hash fields. Signing goes through signer.action_sign() (driven by the public portal
controller /sign_oca/sign/<id>/<token>), which burns fields into the PDF, recomputes
current_hash, sets signed_on/signature_hash, and extends the no-gap inalterable hash
chain. Writing these by hand produces a document whose altered_hash flips True (or a
broken sequence) — i.e. you manufacture a "tampered" record. To test signing end-to-end,
open the signer's access_url in the portal, don't fake the fields.
A request needs its signatory_data JSON, and creating raw skips it. The placeable
fields live in request.signatory_data (a JSON dict keyed by item id). The template
wizards populate it via template._get_signatory_data(); a raw create gives you an
empty form with nothing to sign. Either go through sign.oca.template.generate[.multi]
(recipe 4), or place fields in draft with add_item({...}) (draft-only, needs ≥1 signer
first) — never leave a sent request with empty signatory_data.
Field placement is percent-of-page (0–100), origin top-left, and role-scoped. Each
item's position_x/y/width/height are percentages of the page box; role_id decides
which signer fills it. A field's page is 1-indexed. action_sign only writes items
whose role_id matches the signing signer — a field on the wrong role is never filled.
state='2_signed' is automatic; don't set it, and mind partial signing. _check_signed
flips a 0_sent request to 2_signed only when all signer_ids have signed_on.
With multiple signers, order isn't enforced by the module (each signs via their own token);
the request is "done" only when the last one signs. Judge completion from
state/signed/signed_count==signer_count, never from "one person signed."
Everything meaningful is per-company_id — except the field/role catalogs.
sign.oca.request carries company_id (req); requests, signers, and logs are tenant/company
scoped. But sign.oca.field and sign.oca.role have no company field — they're shared
catalogs seeded per DB. Confirm you're on the intended tenant DB before operating (see
[[everjust-platform]] / [[everjust-agent-mcp]]); the same "Customer" role id means different
things on different tenants.
partner_id on a signer is ondelete='restrict', role_id too. You can't create a
signer for a non-existent partner, and you can't delete a partner/role still referenced by a
signer. Resolve/create the res.partner first. When generating from a template, the role's
partner_selection_policy decides auto-resolution: empty → you must supply the partner,
default → default_partner_id, expression → evaluates expression_partner against the
linked record_ref (e.g. {{object.partner_id.id}}).
record_ref is a Reference field ("model,id" string), and it excludes sign.oca.*.
The link back to the originating business record is record_ref — write it as
"res.partner,42", not an int. The field domain deliberately hides sign.oca models, so
you can't point a request at another sign request. The signer's model/res_id are
read-only mirrors computed from it.
1---2name: everjust-sign3description: Operate the "Sign" e-signature app of an everjust.app tenant (create a signable PDF request, add typed signers by role, send it out for signature, track who has signed, download the finished signed PDF, and read the tamper-evidence log) via the Odoo MCP/ORM. Use when the task is to send a document out for signature from an everjust.app workspace, check a signature request's status, find pending signers, generate a request from a reusable template, or verify a signed document's integrity hash. This is the OCA sign_oca stack (sign.oca.request / .signer / .template) — NOT Odoo Enterprise "Sign" (sign.request/sign.item) and NOT DocuSign. Signers sign in a public portal via a tokenized link; invite emails go out through Odoo chatter (message_notify), so they ride the everjust mail send-gates — see [[everjust-mail-ops]]. Cross-references [[everjust-platform]] and [[everjust-agent-mcp]]; sibling of [[everjust-sms]] and [[everjust-mail-ops]].4---56# EVERJUST Sign — Agent Skill78Operate the **Sign** app of an everjust.app tenant as a running agent: turn a PDF into9a signature request, attach signers by role, send it, track signing progress, pull the10finished signed PDF, and read the audit/integrity trail — all through the Odoo MCP / ORM11(`search`, `get`, `create`, `update`, `call` a method). See [[everjust-agent-mcp]] for how12to open a session against the right tenant (`https://<tenant>.everjust.app/mcp`, Bearer =13Odoo API key, runs AS that user, every call audited).1415**This is `sign_oca`** — the community **OCA "Sign Oca"** addon (author Dixmit/OCA,16`19.0.1.0.0`, AGPL-3), rebranded only in strings to "EVERJUST.APP". It is a **different17product** from Odoo Enterprise Sign. There is **no `sign.request` / `sign.item`** here;18the models are `sign.oca.*`. Stock OCA behavior, no everjust fork of the module itself19(see Pitfalls for the platform-level gotchas that DO apply).2021## When to use this skill2223- **Send a document out for signature** — from a one-off PDF or from a reusable template.24- **Check a request's status** — who has signed, who is pending, is it fully signed / cancelled.25- **Find pending signers** — the queue of signatures a partner (or the tenant) still owes.26- **Generate requests from a template** — one, or in bulk over a set of business records27 (each request linked back to its record via `record_ref`).28- **Download the finished signed PDF** and **verify its tamper-evidence** (per-signer29 inalterable hash chain).3031**Do NOT use this skill for**, and stop if the task is really:32- **Odoo Enterprise Sign** (`sign.request`, `sign.item`, `sign.template`) or **DocuSign /33 external e-sign** — this tenant runs `sign_oca` only; those models do not exist here.34- **Making the invite email actually deliver** — the invite is an Odoo `message_notify`35 (chatter notification). Whether it lands is a mail-transport question owned by36 [[everjust-mail-ops]] (verified sending identity, suppression, rate-gate). This skill37 fires the invite; that skill tells you if it went out.38- **SMS / OTP signer authentication** — `sign_oca` has **no SMS and no OTP** (OTP is on the39 module ROADMAP, unbuilt). Do not expect a text to the signer; see [[everjust-sms]] if you40 separately want to text them a heads-up.41- **Registrar / DNS / product work on connectdomain** — unrelated; that is [[godaddy-api]].4243## Where it's installed4445Live introspected on the shared everjust box (one Postgres DB per tenant). `sign_oca` is46**installed** on: `connectdomain`, `burekraft-llc`, `headsup`, `riftline-labs`,47`tcstartupweek`, `trust-works-company`, `weldon`. **Not present** on `control`. Confirm the48tenant before operating; everything is per-`company_id` (see Pitfalls). On a fresh tenant49the seed data ships 6 fields (Name, Email, Phone, Text, Signature, Check) and 2 roles50(Customer, Employee, both `partner_selection_policy='empty'`) — verified on `connectdomain`.5152---5354## The model map5556Three core models plus a field-catalog, a role catalog, a template-item child, and an57append-only log. All the request/signer models inherit `mail.thread` + `mail.activity.mixin`58(so they have chatter and activities — usable, but the *signing* audit trail is the59dedicated `sign.oca.request.log`, not chatter).6061| Model | Role | Key fields (real, from source + live `fields_get`) |62|---|---|---|63| `sign.oca.template` | **Reusable blueprint**: a PDF + placed fields + roles. Optionally bound to a business model. | `name` (req), `data` (binary PDF, req, `attachment=True`), `filename`, `item_ids` (→ `sign.oca.template.item`), `model_id` (→ `ir.model`) / `model` (computed char), `ask_location`, `active`, `request_count`, `request_ids` |64| `sign.oca.template.item` | One **placed field** on a template page (position is % of page, 0–100). | `template_id` (req), `field_id` (→ `sign.oca.field`), `role_id` (→ `sign.oca.role`, default = Customer), `required`, `page` (req, default 1), `position_x`/`position_y` (req, %), `width`/`height` (%), `placeholder` |65| `sign.oca.request` | **A live signature request** over one PDF. This is the thing you send and track. | `name` (req), `data` (binary PDF, req — mutated in place as signers sign), `template_id`, `state` (req: `1_draft`→`0_sent`→`2_signed`/`3_cancel`), `signed` (bool), `signer_ids` (→ signer), `signer_id` (computed: the signer matching the current user), `signatory_data` (**JSON** — the placed fields), `to_sign`/`signed_count`/`signer_count` (computed), `current_hash`, `record_ref` (Reference → the linked business record), `user_id` (Responsible, req), `company_id` (req), `ask_location` |66| `sign.oca.request.signer` | **One signer's slot** on a request: who, in what role, with token + signed state + integrity hash. | `request_id` (req), `partner_id` (→ res.partner, req), `role_id` (→ sign.oca.role, req), `signed_on` (datetime — **None until they sign**), `signature_hash`, `access_token`/`access_url` (portal link, from `portal.mixin`), `is_allow_signature` (computed: can the current user sign this now), `secure_sequence_number` + `inalterable_hash` + `altered_hash` (the tamper chain), `latitude`/`longitude`, `data` (related → request PDF), `model`/`res_id` (mirror of the linked record) |67| `sign.oca.field` | **Catalog of field TYPES** you can place. Seeded, tenant-wide (no company scope). | `name`, `field_type` (`text`\|`signature`\|`check`), `default_value` (a partner attr name like `name`/`email`/`phone`, or False) |68| `sign.oca.role` | **Catalog of signer roles** + how to auto-resolve the partner when generating from a template. | `name` (req), `partner_selection_policy` (`empty`\|`default`\|`expression`, req), `default_partner_id`, `expression_partner` (e.g. `{{object.partner_id.id}}`), `domain` (req) |69| `sign.oca.request.log` | **Append-only audit trail** (`_log_access=False`). Every view/configure/sign/cancel with actor IP. This is the legal trail, not chatter. | `request_id`, `signer_id`, `action` (`create`/`validate`/`view`/`sign`/`add_field`/`edit_field`/`delete_field`/`cancel`/`configure`), `uid`, `partner_id`, `date`, `ip`, `access_token` |7071### The lifecycle (state machine)7273```74create (1_draft) ──action_send()──▶ 0_sent ──all signers signed──▶ 2_signed75 │ (auto, via _check_signed)76 └──cancel()──▶ 3_cancel77```7879- **`1_draft`** — created, fields placeable/editable (`add_item`/`set_item_data`/`delete_item`80 only work in draft, and require ≥1 signer). Not yet visible to signers.81- **`0_sent`** — `action_send()` was called: it stamps a `validate` log, sets state, mints a82 portal token per signer, and fires the invite `message_notify` to each signer's partner.83 Signers can now sign at their `access_url`.84- **`2_signed`** — reached **automatically** when the last signer signs (`_check_signed`85 flips it once every `signer_ids.signed_on` is set). Do not set it by hand.86- **`3_cancel`** — via `cancel()`.8788### How signing actually mutates the PDF (don't fake it)8990`signer.action_sign(items, ...)` (called by the public portal controller, not you) re-renders91the `request.data` PDF in place — burning each field's value (text / signature image /92check-cross) at its `position_x/y` (% of page), recomputes `current_hash` (SHA-1 of the new93PDF), sets the signer's `signed_on` + `signature_hash`, then extends the **inalterable hash94chain** (`secure_sequence_number` + `inalterable_hash`, chained off the previous signer via a95no-gap `ir.sequence`). This is why you never write `data`/`signed_on`/`signatory_data` by96hand — see Pitfalls.9798---99100## Recipes101102Route each through the Odoo MCP (`call`/`create`/`update`/`search`/`get` on the tenant).103`env[...]` shown for clarity; over MCP these are the generic tools operating on the model.104105### 1. Create a one-off request from a raw PDF and send it106107```python108import base64109pdf_b64 = base64.b64encode(open("nda.pdf","rb").read()).decode()110111# a partner to sign as (must exist; create/find via res.partner)112partner = env["res.partner"].search([("email","=","alice@example.com")], limit=1)113customer_role = env["sign.oca.role"].search([("name","=","Customer")], limit=1)114115req = env["sign.oca.request"].create({116 "name": "NDA — Alice",117 "data": pdf_b64, # required; base64 PDF118 "filename": "nda.pdf",119 "signer_ids": [(0, 0, {120 "partner_id": partner.id,121 "role_id": customer_role.id,122 })],123})124# req.state == "1_draft" now. Place fields if needed (recipe 3), then send:125req.action_send(message="Please sign the attached NDA.")126# -> state="0_sent", tokens minted, invite message_notify fired to each signer.127```128`action_send` returns nothing meaningful; **verify by re-reading** `state` and the signer's129`access_url`. The invite is a chatter notification — its *delivery* is a mail-transport130concern ([[everjust-mail-ops]]): a request can be `0_sent` while the email never left (blocked131sending identity, suppression, rate-gate). If a signer says they got no email, hand them the132`access_url` directly.133134### 2. Track status — who has signed, who is pending135136```python137req = env["sign.oca.request"].browse(req_id)138req.read(["name","state","signed","signer_count","signed_count"])139# Per-signer detail: signed_on is None until they sign.140env["sign.oca.request.signer"].search_read(141 [("request_id","=",req.id)],142 ["partner_name","role_id","signed_on","access_url","secure_sequence_number"])143```144`state == "2_signed"` (or `signed == True`) is the only reliable "done" signal — it flips145automatically when the last signer signs. `signed_count == signer_count` says the same.146A signer with `signed_on == False` is still pending.147148### 3. Find everyone's pending signatures (the queue)149150```python151# Pending signatures owed by a specific partner (across all requests):152env["sign.oca.request.signer"].search_read(153 [("request_id.state","=","0_sent"),154 ("partner_id","=",partner.id),155 ("signed_on","=",False)],156 ["request_id","role_id","access_url"])157158# Tenant-wide "what's out for signature but not done":159env["sign.oca.request"].search_read(160 [("state","=","0_sent")],161 ["name","signer_count","signed_count","user_id"], order="create_date desc")162```163The web systray count comes from `res.users.sign_oca_request_user_count()` — you can `call`164it, but for an agent the explicit `search_read` above is clearer.165166### 4. Generate a request from a template, resolving signers by role167168```python169tmpl = env["sign.oca.template"].search([("name","=","Employee Contract")], limit=1)170tmpl.read(["name","model","request_count"])171# Which roles the template needs and how each resolves its partner:172env["sign.oca.role"].browse(tmpl.item_ids.mapped("role_id").ids).read(173 ["name","partner_selection_policy","default_partner_id","expression_partner"])174175# Preferred path — the wizard builds signatory_data + signers correctly:176wiz = env["sign.oca.template.generate"].with_context(default_template_id=tmpl.id).create({177 "template_id": tmpl.id,178 "signer_ids": [(0, 0, {"role_id": role_id, "partner_id": partner_id})179 for (role_id, partner_id) in signer_pairs],180 "message": "<p>Please sign.</p>",181})182wiz.generate() # creates the sign.oca.request, action_send()s it, returns the sign action183184# Bulk over N business records (each request linked via record_ref):185env["sign.oca.template.generate.multi"].with_context(186 model="res.partner", active_ids=[p1,p2,p3]187).create({"template_id": tmpl.id, "message": "<p>Sign</p>"}).generate()188```189Prefer the **wizards** (`sign.oca.template.generate` / `.multi`) over hand-building a request:190they call `template._get_signatory_data()` to snapshot the placed fields into the request's191`signatory_data` JSON and set signers from roles. Hand-building means you must replicate that192JSON yourself (see Pitfall 4). For a template bound to a `model_id`, the multi wizard is the193"Sign from template" server action; each resulting request carries `record_ref` back to its194source record.195196### 5. Download the finished signed PDF + verify integrity197198```python199req = env["sign.oca.request"].browse(req_id)200assert req.state == "2_signed"201pdf_bytes = base64.b64decode(req.data) # the burned-in, fully-signed PDF202open("signed.pdf","wb").write(pdf_bytes)203204# Tamper-evidence: every signed signer has a chained hash; altered_hash must be False.205env["sign.oca.request.signer"].search_read(206 [("request_id","=",req.id), ("signed_on","!=",False)],207 ["partner_name","signed_on","signature_hash",208 "secure_sequence_number","inalterable_hash","altered_hash","latitude","longitude"])209210# Full legal audit trail (who did what, from which IP):211env["sign.oca.request.log"].search_read(212 [("request_id","=",req.id)],213 ["date","action","uid","partner_id","ip","signer_id"], order="date")214```215`altered_hash == True` on any signer means the chained hash no longer matches — treat the216document as tampered. The `sign.oca.request.log` is the append-only trail (`_log_access=False`,217IP-stamped); it is the evidence record, not the chatter on the request.218219### 6. Cancel a request220221```python222env["sign.oca.request"].browse(req_id).cancel() # -> state "3_cancel", logs "cancel"223```224There is no un-cancel and no built-in re-send of a cancelled request; generate a fresh one.225226---227228## Pitfalls2292301. **This is OCA `sign_oca`, NOT Odoo Enterprise Sign.** Reach for `sign.oca.request` /231 `sign.oca.request.signer` / `sign.oca.template` — `sign.request`, `sign.item`,232 `sign.template` do not exist on these tenants. Don't import Enterprise-Sign assumptions233 (roles, tags, send-request wizard) — the API is the one documented here.2342352. **The invite email is chatter, and it can silently not deliver.** `action_send` fires236 `mail.thread.message_notify` per signer — an Odoo *notification*, subject to this237 platform's mail send-gates (verified sending identity, suppression, hourly cap — see238 [[everjust-mail-ops]]). A request sitting in `0_sent` is **not** proof the signer was239 emailed. If in doubt, read the signer's `access_url` and deliver the link yourself.240 There is **no SMS/OTP** path (unbuilt — ROADMAP); don't promise the signer a text.2412423. **Never hand-mutate `data`, `signed_on`, `signatory_data`, `state='2_signed'`, or the243 hash fields.** Signing goes through `signer.action_sign()` (driven by the public portal244 controller `/sign_oca/sign/<id>/<token>`), which burns fields into the PDF, recomputes245 `current_hash`, sets `signed_on`/`signature_hash`, and extends the no-gap inalterable hash246 chain. Writing these by hand produces a document whose `altered_hash` flips True (or a247 broken sequence) — i.e. you manufacture a "tampered" record. To *test* signing end-to-end,248 open the signer's `access_url` in the portal, don't fake the fields.2492504. **A request needs its `signatory_data` JSON, and creating raw skips it.** The placeable251 fields live in `request.signatory_data` (a JSON dict keyed by item id). The template252 wizards populate it via `template._get_signatory_data()`; a raw `create` gives you an253 *empty* form with nothing to sign. Either go through `sign.oca.template.generate[.multi]`254 (recipe 4), or place fields in draft with `add_item({...})` (draft-only, needs ≥1 signer255 first) — never leave a sent request with empty `signatory_data`.2562575. **Field placement is percent-of-page (0–100), origin top-left, and role-scoped.** Each258 item's `position_x/y/width/height` are percentages of the page box; `role_id` decides259 *which signer* fills it. A field's `page` is 1-indexed. `action_sign` only writes items260 whose `role_id` matches the signing signer — a field on the wrong role is never filled.2612626. **`state='2_signed'` is automatic; don't set it, and mind partial signing.** `_check_signed`263 flips a `0_sent` request to `2_signed` only when **all** `signer_ids` have `signed_on`.264 With multiple signers, order isn't enforced by the module (each signs via their own token);265 the request is "done" only when the last one signs. Judge completion from266 `state`/`signed`/`signed_count==signer_count`, never from "one person signed."2672687. **Everything meaningful is per-`company_id` — except the field/role catalogs.**269 `sign.oca.request` carries `company_id` (req); requests, signers, and logs are tenant/company270 scoped. But `sign.oca.field` and `sign.oca.role` have **no company field** — they're shared271 catalogs seeded per DB. Confirm you're on the intended tenant DB before operating (see272 [[everjust-platform]] / [[everjust-agent-mcp]]); the same "Customer" role id means different273 things on different tenants.2742758. **`partner_id` on a signer is `ondelete='restrict'`, `role_id` too.** You can't create a276 signer for a non-existent partner, and you can't delete a partner/role still referenced by a277 signer. Resolve/create the `res.partner` first. When generating from a template, the role's278 `partner_selection_policy` decides auto-resolution: `empty` → you must supply the partner,279 `default` → `default_partner_id`, `expression` → evaluates `expression_partner` against the280 linked `record_ref` (e.g. `{{object.partner_id.id}}`).2812829. **`record_ref` is a Reference field (`"model,id"` string), and it excludes `sign.oca.*`.**283 The link back to the originating business record is `record_ref` — write it as284 `"res.partner,42"`, not an int. The field domain deliberately hides `sign.oca` models, so285 you can't point a request at another sign request. The signer's `model`/`res_id` are286 read-only mirrors computed from it.