Salesforce already has a real audit trail. CreatedById and LastModifiedById
on every record, Field History Tracking with before and after values per field,
and the Setup Audit Trail for configuration. It is authoritative, queryable with
SOQL, retained under your org's policy, and already inside whatever compliance
regime you run.
The one thing that makes it useless for an agent is a shared service account. If
every change mOperator makes says "mOperator Integration User", the trail records
that a bot did everything and answers none of the questions anyone actually asks
— who changed this, why, on whose authority.
So mOperator does not build its own audit log. It makes Salesforce's audit log
correct, by having each person's changes carry their own Salesforce identity.
A Salesforce change is recorded under the person who asked for it. If it cannot
be, the change does not happen.
There is no silent fallback to the service account. That is the entire point: a
quiet downgrade produces exactly the audit trail you believe you have and do not,
and you would not find out until someone asked a question it cannot answer.
Concretely, a write is refused — with a reason, not a stack trace — when:
SFDC_IDENTITY=user # default
| Mode | Writes | Reads |
|---|---|---|
user | the requester's own identity | service account |
user-all | the requester's own identity | the requester's own identity |
service | service account | service account |
user is the default because reads carry no attribution value and nobody should
have to sign in to ask a question.
user-all is worth considering for a second reason that has nothing to do with
auditing: reads then run under the person's own Salesforce permissions, so
sharing rules and field-level security apply and the agent cannot surface a
record they could not open themselves. If your org uses record-level sharing
seriously, this closes a real hole.
service is an explicit opt-out. Choose it knowingly — every change will read as
the integration user.
/chat.Nobody runs a command to connect. salesforce_connection_status answers "am I
connected?" without having to attempt a write to find out.
Grants are keyed by email, so one person has one grant whether they reach the
agent from Slack or from the browser.
When a write parks for approval and somebody else clicks Approve, eve makes the
approver the turn's current caller. Salesforce is still told the requester
made the change, because they are the one who wanted it — and the approval itself
is recorded in the Slack thread, which is durable and searchable.
This is why an approver cannot rescue a write from someone who has not connected:
attributing it to the approver would misreport who wanted the change, which is
the failure this design exists to prevent.
SFDC_IDENTITY=user # the default; set it explicitly so intent is visible
Your Connected App, same one the service account uses
SALESFORCE_CLIENT_ID=
SALESFORCE_CLIENT_SECRET=
SALESFORCE_LOGIN_URL=https://login.salesforce.com
Encrypts stored refresh tokens. Generate: openssl rand -hex 32
MOPERATOR_TOKEN_ENCRYPTION_KEY=
Required — grants are stored here
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
If Redis or the encryption key is missing, writes are refused with a message
naming what is unset. They do not fall back to the service account — see the rule
above. Run npm run agent:info and check the Salesforce entry before deploying.
The framework mints the callback URL, so read it rather than guessing. Run the
agent locally, trigger a write, and copy the URL from the sign-in prompt:
npm run agent
# then: "update the Description on Campaign 701xxx to 'test'"
Add that URL to your Connected App under Setup → App Manager → your app →
Edit → Callback URL. You will need both the local and the production form.
api, refresh_token, offline_accessHave one person trigger a write, complete the sign-in, and then check the record
in Salesforce. LastModifiedById should be them, not the service account.
Per-user identity makes the trail meaningful; these make it complete. Both
are org configuration, not something this repo can set:
Name the service account something unmistakable — "mOperator Integration User"
rather than a person's name — so the changes that legitimately are the bot's
(reads under user mode, anything under service) are obvious in a report.
| Caller | Credentials |
|---|---|
A person in Slack or /chat who has signed in | Their own Salesforce account |
| A person who has not signed in yet | Prompted; the turn waits durably |
| An approver resuming someone else's parked write | Recorded as the requester, or refused if they never connected |
| A scheduled run | Refused. There is nobody to attribute to. |
The data-analyst subagent | The service account; it is read-only |
Scheduled runs deliberately do not attempt a sign-in — a cron job has no human to
answer a prompt, so it would park forever — and they are not allowed to write as
the service account either. Keep scheduled work to reading and reporting; the
digests that ship with this repo already do.
One person: in Salesforce, Setup → Connected Apps OAuth Usage → your app →
User's Access → Revoke. The next call fails its refresh and they are asked to
sign in again.
Everyone: rotate MOPERATOR_TOKEN_ENCRYPTION_KEY. Every stored grant becomes
undecryptable, is treated as "not connected", and each person is re-prompted. This
is the fastest way to invalidate the whole set.
Turning it off: set SFDC_IDENTITY=service. Writes go back to the service
account immediately, and every change from then on is recorded as the integration
user. Stored grants remain in Redis until their TTL
expires; delete the moperator:sfdc-user-token:* keys if you want them gone now.
What this improves. Attribution is real. Salesforce's own permission model
applies per person, so someone who cannot edit Opportunities cannot get the agent
to edit one for them. Revocation is per person and immediate.
What it does not change. The service account still exists and is still used
for reads, schedules, and anyone who has not signed in — so scope it tightly
regardless. A refresh token in Redis is a real credential: encrypted at rest, but
someone with both your Redis credentials and MOPERATOR_TOKEN_ENCRYPTION_KEY can
use it. Keep them in different places, and prefer a Vercel project whose
environment variables are scoped to the people who need them.
What it does not do. This is authentication, not authorization. Whether the
agent should make a change is decided by the approval policies inagent/lib/approval.ts. Per-user OAuth answers "in whose name", not "should this
happen at all" — you want both.
agent/lib/salesforce/auth.ts the flow: getToken, start, complete
agent/lib/salesforce/token-store.ts encrypted grants in Redis, 90-day TTL
agent/lib/salesforce/crypto.ts AES-256-GCM with a per-value IV
agent/lib/salesforce/client.ts accepts injected credentials
Every Salesforce tool calls resolveSfdcCredentials(ctx) and passes the result to
the client. That function returns null — meaning "use the service account" —
whenever the feature is off, the caller is not a person, or the store is not
configured, which is why enabling this cannot break an install.