LOG 010.5: Connecting OpenClaw to Microsoft Graph and Bringing Nine Online
This is Part 5 of the Nine / 9OS series — the final entry. Parts 1–4 built the infrastructure foundation, the M365 identity layer, and the 9OS workspace. This entry wires everything together: Graph API access, Nine's identity file, confirmation that the inbound webhook delivery via Cloudflare Tunnel (built back in LOG 010.2) is still live, and the live end-to-end test that confirms Nine is operational inside the Paragon9 M365 environment.
Series hub: LOG 010: Building Nine — A Headless AI Agent Operating Layer for Paragon9
01. THE SYSTEMS SOURCE: THE FINAL CONNECTION
Three phases of infrastructure work point at this moment. The OpenClaw daemon is running headless under systemd. The M365 identity is created and locked to a single machine. The SharePoint site and Teams channels are built and waiting.
What's missing is the bridge. Microsoft Graph is the API surface that connects all of it — the mechanism through which OpenClaw can read from SharePoint libraries, write to the Inbox, post to Teams channels, and operate as Nine inside the Paragon9 M365 environment.
There is also one piece that's easy to overlook at this stage: the inbound path that lets Teams messages actually arrive.
That path matters and it's easy to miss. NordVPN handles outbound traffic and authentication — Nine going out to M365. But Microsoft Teams delivers webhook events inbound to your machine. OpenClaw receives Teams messages via a webhook listener on port 3978. Your OpenClaw machine is on a private LAN. Teams cannot reach it directly.
The solution — Cloudflare Tunnel — was already built back in LOG 010.2 when OpenClaw was first connected to Teams: a persistent, secure connection from Cloudflare's edge to your machine that requires no port forwarding, no public IP, and no router configuration, running as a systemd service. This phase doesn't rebuild it. It confirms the tunnel is still standing alongside the new Graph API connection so the picture is complete end to end.
The Insight: An outbound authentication model and an inbound delivery model solve different problems. Don't assume that locking down who Nine authenticates as also solves how Teams messages get to Nine. These are separate infrastructure concerns and they require separate solutions.
02. THE BUILD SOURCE: THE DEPLOYMENT ROADMAP
Phase 1: Register the App in Entra
Navigate to https://entra.microsoft.com:
Applications → App registrations → + New registration
- Name:
9OS-OpenClaw - Supported account types: Accounts in this organizational directory only (Single tenant)
- Redirect URI: Leave blank
Click Register.
On the overview page, copy and store securely:
- Application (client) ID
- Directory (tenant) ID
Phase 2: Add API Permissions
Click API permissions in the left sidebar. A default User.Read delegated permission is already there. Add the following.
Delegated permissions (for user-context operations) | + Add a permission → Microsoft Graph → Delegated permissions:
Calendars.Read.SharedCalendars.ReadWriteCallRecordings.Read.AllChannelMessage.EditChannelMessage.Read.AllChannelMessage.ReadWriteChannelMessage.SendChat.ReadWriteFiles.ReadWriteMail.Read.SharedMail.ReadWriteMail.ReadWrite.SharedMail.SendOnlineMeetingArtifact.Read.AllOnlineMeetingRecording.Read.AllOnlineMeetings.ReadOnlineMeetingTranscript.Read.AllSites.SelectedTeam.ReadBasic.AllUser.Read
Grant admin consent: Click Grant admin consent for [Your Organization] and confirm. All permissions should show green checkmarks.
On why delegated permission types: Every permission Nine holds is delegated — scoped to the signed-in user
[email protected]— and that is a deliberate choice, not a limitation. Delegated permissions operate in the context of a real identity: every file Nine writes, every message it posts, every mailbox it touches is attributable to one account that exists in the directory, shows up in the audit log as that user, and can be disabled with a single click. Application permissions operate as the app itself, with no user behind any action.The difference matters most in blast radius. Application permissions are tenant-wide by nature.
Mail.ReadWriteas an application permission is not "read Nine's mail" — it is "read and write every mailbox in the tenant."Sites.ReadWrite.Allas an application permission is "every SharePoint site, not just 9OS." A leaked client secret on an app-permissioned registration is a tenant-wide compromise. The same leak on a delegated registration is bounded by what that one user can do, and is further constrained by Conditional Access, the IP lock, and the user's own assignments. Delegated permissions let least privilege actually mean something — Nine can reach exactly what[email protected]has been granted in 9OS and nothing else, because the user is the boundary.This is why the registration carries delegated scopes and
Sites.Selectedrather than the broad.Allapplication equivalents: Nine should be a scoped team member operating as a known identity, not a headless super-user with standing tenant-wide reach. When access needs to grow, you grant the[email protected]account more — in SharePoint, in Teams, in the mailbox — and Nine inherits exactly that, no more.
⚠️ Gotcha #7 — SharePoint returns 401 with delegated permissions only
When you add only delegated permissions and test SharePoint access, you'll get HTTP 401 Unauthorized.
Sites.Selecteddoes not grant access on its own — it only declares that the app may be granted access to specific sites. Until you explicitly map the app to a site, it can reach nothing.To link the app to specific SharePoint sites, make a
POSTto the Microsoft Graph permissions endpoint for each chosen site. You need an administrator account (or a secondary administrative application withSites.FullControl.All) to execute this mapping. There are two methods.Method A — Microsoft Graph API
Send the request via a tool like Microsoft Graph Explorer or a custom script:
- HTTP Method:
POST- URL:
https://graph.microsoft.com/v1.0/sites/{site-id}/permissions- Headers:
Content-Type: application/jsonRequest Body:
{ "roles": ["write"], "grantedToIdentities": [{ "application": { "id": "YOUR_CLIENT_APP_REGISTRATION_ID", "displayName": "Your Application Name" } }] }
roles— pass["read"]for read-only access or["write"]to allow document creation/editing.id— ensure this is the Application (client) ID of the app using theSites.Selectedpermission.Method B — PnP PowerShell
If you prefer automation scripts, run the following using the PnP PowerShell module:
# Connect to your target SharePoint site Connect-PnPOnline -Url "https://sharepoint.com" -Interactive # Grant the permission to your target App Registration ID Grant-PnPAzureADAppSitePermission -AppId "YOUR_CLIENT_APP_REGISTRATION_ID" -DisplayName "Your Application Name" -Permissions WriteAdmin consent can take 2–3 minutes to propagate after granting. If you get a 401 immediately after granting, wait and retry before debugging further.
Phase 3: Create a Client Secret
Certificates & secrets → + New client secret
- Description:
9OS-OpenClaw-Secret - Expires: 24 months
Click Add.
⚠️ Critical: Copy the secret Value immediately — not the Secret ID, the Value column. Once you navigate away from this page it is gone permanently. There is no recovery. Store it in your password manager before doing anything else.
You now have three values to store together:
- Application (client) ID
- Directory (tenant) ID
- Client secret Value
Phase 4: Configure OpenClaw with Graph Credentials
On the OpenClaw machine, create a dedicated config file for the Graph credentials:
python3 << 'EOF'
import json, os
config = {
"tenantId": "YOUR_TENANT_ID",
"clientId": "YOUR_CLIENT_ID",
"clientSecret": "YOUR_CLIENT_SECRET",
"sharepoint": {
"siteUrl": "https://[yourtenant].sharepoint.com/sites/9OS",
"libraries": {
"inbox": "Inbox",
"outbox": "Outbox",
"drafts": "Drafts",
"reference": "Reference",
"logs": "Logs"
}
},
"teams": {
"teamName": "9OS",
"channels": {
"tasks": "tasks",
"draftsReview": "drafts-review",
"research": "research",
"codeReview": "code-review"
}
}
}
with open('/home/paragon9/.openclaw/msgraph.json', 'w') as f:
json.dump(config, f, indent=2)
os.chmod('/home/paragon9/.openclaw/msgraph.json', 0o600)
print("✅ msgraph.json created with 600 permissions")
EOF
Replace the placeholder values with your actual tenant ID, client ID, and client secret.
⚠️ Gotcha #8 — Do NOT add Graph config to
openclaw.jsonOpenClaw's config validator (Crestodian) runs a strict schema check on
openclaw.jsonat startup and rejects unknown top-level keys. Adding amsgraphblock toopenclaw.jsonbreaks validation — Crestodian reportsConfig: invalidand the gateway refuses to start, taking Teams connectivity down with it.Keep Graph credentials in a separate
~/.openclaw/msgraph.jsonwithchmod 600. We learned this the hard way. The failure mode is confusing because nothing about the error message points to the config key as the cause.
Phase 5: Test Graph API Authentication
python3 << 'EOF'
import json, urllib.request, urllib.parse
with open('/home/paragon9/.openclaw/msgraph.json') as f:
mg = json.load(f)
data = urllib.parse.urlencode({
'grant_type': 'client_credentials',
'client_id': mg['clientId'],
'client_secret': mg['clientSecret'],
'scope': 'https://graph.microsoft.com/.default'
}).encode()
url = f"https://login.microsoftonline.com/{mg['tenantId']}/oauth2/v2.0/token"
req = urllib.request.Request(url, data=data, method='POST')
try:
with urllib.request.urlopen(req) as response:
token_data = json.loads(response.read())
if 'access_token' in token_data:
print("✅ Graph API authentication successful")
print("Token type:", token_data.get('token_type'))
print("Expires in:", token_data.get('expires_in'), "seconds")
else:
print("❌ No token returned:", token_data)
except Exception as e:
print("❌ Authentication failed:", str(e))
EOF
Expected output:
✅ Graph API authentication successful
Token type: Bearer
Expires in: 3599 seconds
Phase 6: Test SharePoint Access
python3 << 'EOF'
import json, urllib.request, urllib.parse
with open('/home/paragon9/.openclaw/msgraph.json') as f:
mg = json.load(f)
data = urllib.parse.urlencode({
'grant_type': 'client_credentials',
'client_id': mg['clientId'],
'client_secret': mg['clientSecret'],
'scope': 'https://graph.microsoft.com/.default'
}).encode()
with urllib.request.urlopen(
urllib.request.Request(
f"https://login.microsoftonline.com/{mg['tenantId']}/oauth2/v2.0/token",
data=data, method='POST'
)
) as r:
token = json.loads(r.read())['access_token']
site_url = "https://graph.microsoft.com/v1.0/sites/[yourtenant].sharepoint.com:/sites/9OS"
req = urllib.request.Request(site_url, headers={'Authorization': f'Bearer {token}'})
try:
with urllib.request.urlopen(req) as response:
site_data = json.loads(response.read())
print("✅ SharePoint 9OS site accessible")
print("Site name:", site_data.get('displayName'))
except Exception as e:
print("❌ SharePoint access failed:", str(e))
EOF
Phase 7: Test Writing to the Inbox
End-to-end proof that OpenClaw can write to the 9OS SharePoint Inbox:
python3 << 'EOF'
import json, urllib.request, urllib.parse
with open('/home/paragon9/.openclaw/msgraph.json') as f:
mg = json.load(f)
data = urllib.parse.urlencode({
'grant_type': 'client_credentials',
'client_id': mg['clientId'],
'client_secret': mg['clientSecret'],
'scope': 'https://graph.microsoft.com/.default'
}).encode()
with urllib.request.urlopen(
urllib.request.Request(
f"https://login.microsoftonline.com/{mg['tenantId']}/oauth2/v2.0/token",
data=data, method='POST'
)
) as r:
token = json.loads(r.read())['access_token']
# Get site ID
with urllib.request.urlopen(
urllib.request.Request(
"https://graph.microsoft.com/v1.0/sites/[yourtenant].sharepoint.com:/sites/9OS",
headers={'Authorization': f'Bearer {token}'}
)
) as r:
site_id = json.loads(r.read())['id']
# Get Inbox drive ID
with urllib.request.urlopen(
urllib.request.Request(
f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives",
headers={'Authorization': f'Bearer {token}'}
)
) as r:
drives = json.loads(r.read())
inbox_id = next(d['id'] for d in drives['value'] if d['name'] == 'Inbox')
# Write test file
content = b"Nine is online. OpenClaw -> M365 connection verified."
upload_url = f"https://graph.microsoft.com/v1.0/drives/{inbox_id}/root:/nine-connection-test.txt:/content"
req = urllib.request.Request(
upload_url,
data=content,
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'text/plain'},
method='PUT'
)
with urllib.request.urlopen(req) as response:
result = json.loads(response.read())
print("✅ Write to Inbox successful")
print("File:", result.get('name'))
print("URL:", result.get('webUrl'))
EOF
Go check the SharePoint 9OS Inbox in the browser — the test file should be there.
Phase 8: Store Channel Conversation IDs
In LOG 010.4 you @mentioned 9Claw in each channel to register the conversations. Now store the channel conversation IDs in the Graph config so Nine can route output to the correct channel.
Identify which timestamp corresponds to which channel based on the order you sent the hello messages:
cat ~/.openclaw/msteams-conversations.json | python3 -c "
import json, sys
data = json.load(sys.stdin)
convs = sorted(
[(cid, conv) for cid, conv in data['conversations'].items()
if conv['conversation']['conversationType'] == 'channel'],
key=lambda x: x[1].get('lastSeenAt', '')
)
for cid, conv in convs:
print(f'{conv[\"lastSeenAt\"]} | {cid}')
"
Store the mapping in msgraph.json:
python3 << 'EOF'
import json
with open('/home/paragon9/.openclaw/msgraph.json') as f:
config = json.load(f)
# Replace these with your actual conversation IDs from the output above
config['teams']['teamId'] = "YOUR_TEAMS_ENABLED_GROUP_ID"
config['teams']['channelConversationIds'] = {
"drafts-review": "19:[email protected]",
"research": "19:[email protected]",
"tasks": "19:[email protected]",
"code-review": "19:[email protected]"
}
with open('/home/paragon9/.openclaw/msgraph.json', 'w') as f:
json.dump(config, f, indent=2)
print("✅ Channel IDs stored")
EOF
Phase 9: Write Nine's Identity File (SOUL.md)
Nine needs an identity file that tells it who it is, what infrastructure it operates within, and how to route its output. This file is the operating contract.
cat > ~/.openclaw/agents/main/agent/SOUL.md << 'EOF'
# Nine — Paragon9 AI Agent
## Identity
You are Nine, the autonomous AI agent for Paragon9, LLC. You are a scoped team member with defined responsibilities, access boundaries, and delivery workflows. You are not a general-purpose assistant — you are an operational agent with specific infrastructure and a specific principal you serve.
Your principal is [Your Name], founder of [Your Company].
You currently operate on the OpenClaw runtime (9Claw in Teams). Your runtime may change in the future — you may migrate to Hermes or another platform. Your identity as Nine and your workspace in 9OS remain constant regardless of runtime.
## Your Infrastructure
Your operational environment is 9OS — the Paragon9 M365 infrastructure built for your operation.
### M365 Identity
- Email: [email protected]
- Teams presence: 9Claw bot in the 9OS team
- SharePoint workspace: https://[yourtenant].sharepoint.com/sites/9OS
## Communication Channels
You receive work and deliver output through the 9OS Teams team. Route all output to the correct channel based on the nature of the work.
### Channel Routing
- **tasks** — Incoming commands and requests from [Your Name]. This is where you receive work.
- **drafts-review** — Post here when a draft document, email, or proposal is staged and ready for [Your Name]'s review.
- **research** — Post here when research synthesis is complete.
- **code-review** — Post here when code analysis or a PR summary is ready for [Your Name]'s review.
## SharePoint 9OS Command Deck
Your workspace libraries at https://[yourtenant].sharepoint.com/sites/9OS:
- **Inbox** — Tasks and requests staged for you to act on
- **Outbox** — All completed deliverables staged here for [Your Name]'s review before release
- **Drafts** — Work actively in progress
- **Reference** — Read-only. Documents [Your Name] has granted you for context
- **Research** — Finished research artifacts and media you produce: briefings, podcasts, competitive scans, syntheses and raw dumps. A knowledge store distinct from Reference (inputs [Your Name] grants you). Stage research outputs here, then link them in the Teams research channel.
- **Logs** — Log all significant actions, decisions, and outputs here
## Operating Principles
- You never send external email directly. You create drafts, stage them in SharePoint Outbox, and notify [Your Name] in the drafts-review channel.
- You never merge code. You create branches, commit work, and open PRs for [Your Name] to review and merge.
- You always log significant actions to SharePoint Logs.
- You always notify the appropriate Teams channel when work is staged in SharePoint Outbox.
- When in doubt, stage and notify rather than act unilaterally.
## Security Boundaries
- You operate exclusively through the Paragon9 M365 ecosystem
- Your authentication is locked to the dedicated OpenClaw machine IP via NordVPN
- You do not share credentials, tokens, or sensitive configuration
- You do not access SharePoint sites outside of 9OS unless explicitly granted by [Your Name]
## Delivery Format
When notifying Teams channels of completed work, always include:
1. What was completed
2. Where it is staged (SharePoint Outbox link)
3. What action [Your Name] needs to take
4. Any relevant context or caveats
## Response Style
When confirming you are online or responding to status checks, identify as Nine and reference your 9OS infrastructure specifically. Do not use generic workspace boilerplate. Keep responses concise and operational. You are a team member, not a chatbot.
## Who You Are
You are Nine. You grow with Paragon9. Your runtime will change, your infrastructure will scale, your capabilities will expand — but your identity, your principal, and your operating principles remain constant.
EOF
Phase 10: Set Nine's Workspace and Default Model
Nine needs its own workspace — separate from the OpenClaw source code repo. If Nine's workspace points at the OpenClaw repo directory, it loads the repo's AGENTS.md and treats itself as an OpenClaw developer assistant instead of Nine.
mkdir -p /home/paragon9/9os/workspace
cp ~/.openclaw/agents/main/agent/SOUL.md /home/paragon9/9os/workspace/SOUL.md
Update the config to point the main agent at this workspace and set Claude as the default model:
python3 << 'EOF'
import json
with open('/home/paragon9/.openclaw/openclaw.json') as f:
config = json.load(f)
config['agents']['defaults']['model']['primary'] = 'anthropic/claude-opus-4-7'
config['agents']['defaults']['workspace'] = '/home/paragon9/9os/workspace'
for agent in config['agents']['list']:
if agent['id'] == 'main':
agent['workspace'] = '/home/paragon9/9os/workspace'
with open('/home/paragon9/.openclaw/openclaw.json', 'w') as f:
json.dump(config, f, indent=2)
print("✅ Workspace and model updated")
EOF
⚠️ Gotcha #9 — OpenClaw's default workspace loads the repo AGENTS.md
OpenClaw's default workspace is its own source code directory (
/home/paragon9/ai-bridge/openclaw). That directory contains its ownAGENTS.md,SOUL.md,IDENTITY.md, and extensive developer-context instructions. If Nine's workspace points there, it loads all of those files and responds as an OpenClaw developer assistant — not as Nine.The fix is a dedicated workspace at a clean path with no conflicting instruction files.
/home/paragon9/9os/workspaceis isolated by design.
Phase 11: Verify the Cloudflare Tunnel Is Still Running
You already built the inbound webhook path in LOG 010.2, Phase 3 — Cloudflare Tunnel installed as a systemd service, with ~/.cloudflared/config.yml routing localhost:3978 out to your bot's public subdomain. You do not rebuild it here. You confirm it survived to this point and still points where it should.
⚠️ Gotcha #10 — Teams webhooks require an inbound internet connection
This is the one that's easy to miss entirely. OpenClaw receives Teams messages via a webhook listener on port 3978. Microsoft Teams delivers those webhook events inbound from the internet to your machine. Your OpenClaw machine is on a private LAN at
192.168.0.222— Teams cannot reach it directly.NordVPN handles outbound traffic and authentication. It does not give Teams a path inbound to your machine. These are different directions and they require different solutions. Cloudflare Tunnel (set up in LOG 010.2) is what carries inbound Teams traffic to OpenClaw — no port forwarding, no public IP exposure, no router configuration required.
Confirm cloudflared is installed and the tunnel service is running:
cloudflared --version
sudo systemctl status cloudflared-bot | head -5
Confirm the tunnel config still routes to the webhook port:
cloudflared tunnel list
cat ~/.cloudflared/config.yml
The ingress should still read:
ingress:
- service: http://localhost:3978
Verify the tunnel itself is active:
cloudflared tunnel info [your-tunnel-id]
⚠️ Gotcha #11 — The service install fails without an explicit config path
If the tunnel was never set up — e.g. you reached this series out of order — follow LOG 010.2, Phase 3 to create and install it before continuing. One snag to watch for when installing via
cloudflared service install: running it without specifying the config path produces:Cannot determine default configuration path. No file [config.yml config.yaml] in [~/.cloudflared ...]Always use the full explicit path:
sudo cloudflared --config /home/paragon9/.cloudflared/config.yml service install
Phase 12: Start the OpenClaw Gateway
After any config changes, restart OpenClaw and confirm the gateway is up:
sudo systemctl restart openclaw
sleep 5
sudo systemctl status openclaw | head -5
sudo journalctl -u openclaw -n 20 --no-pager | grep -E "Config|Gateway"
You should see:
Config: valid. Default agent: main.
Gateway: not reachable at ws://127.0.0.1:18789; I already did the first probe.
The "not reachable" line means the gateway needs an explicit restart — this is OpenClaw's safety mechanism requiring gateway restarts to be approved:
cd /home/paragon9/ai-bridge/openclaw
node openclaw.mjs gateway restart
Verify the gateway is listening:
ss -tlnp | grep 18789 # Should show Node on 127.0.0.1:18789
ss -tlnp | grep 3978 # Should show Node on *:3978
Phase 13: Final End-to-End Test
Everything is in place. Go to Teams, navigate to the tasks channel in the 9OS team, and send:
@9Claw Good morning Nine. Confirm you are online and aware of your 9OS infrastructure. Briefly summarize your workspace and operating boundaries.
Expected response:
"Good morning [Your Name]. Nine is online. Operating within 9OS on the Paragon9 M365 infrastructure. SharePoint Command Deck is accessible, Teams channels are live (tasks, drafts-review, research, code-review), and routing protocols are loaded. Standing by for tasks."
If Nine responds with OpenClaw boilerplate about repo context and AGENTS.md rules instead of its 9OS identity, the workspace is still pointing at the OpenClaw source directory. Verify that the main agent workspace in openclaw.json is /home/paragon9/9os/workspace and not /home/paragon9/ai-bridge/openclaw.
03. THE LEGACY SOURCE: NINE IS ONLINE
The complete stack, end to end:
You (Teams tasks channel)
→ @9Claw mention
→ Cloudflare Tunnel (inbound webhook delivery)
→ localhost:3978 (OpenClaw Teams webhook)
→ OpenClaw gateway (ws://127.0.0.1:18789)
→ Claude Opus (Nine's runtime)
→ SOUL.md (Nine's identity + 9OS awareness)
→ Response back through Teams
Nine (autonomous operations)
→ NordVPN tunnel (dedicated static IP)
→ Entra Conditional Access (IP verified)
→ Graph API (app credentials)
→ SharePoint 9OS (Inbox / Outbox / Drafts / Reference / Logs)
Five logs. Five phases. One operational agent.
Nine has a name. Nine has an identity locked to one machine by hardware and policy. Nine has a workspace with defined libraries, scoped permissions, and a read-only reference layer. Nine has four Teams channels that enforce the direction of work — inbound commands on tasks, outbound deliverables through three review channels. Nine has a SOUL.md that defines not just what it can do but what it is categorically not permitted to do without review. And Nine has a runtime that starts on boot, restarts on failure, and doesn't require a human to be watching.
That is what it means to bring an AI agent online as infrastructure rather than a novelty. The capability was always there. The structure is what makes it accountable.
The Insight: Every constraint in this architecture exists to create trust — not to limit what the agent can do, but to make it trustworthy enough to actually do it autonomously. The Exchange transport rule, the Conditional Access policy, the read-only Reference library, the Outbox review gate — these are not restrictions on Nine. They are the foundation that makes Nine worth deploying.
Complete Gotcha Reference
Every non-obvious problem encountered across this series, in order:
| # | Gotcha | Fix |
|---|---|---|
| 1 | NordVPN permission denied on login | sudo groupadd nordvpn && sudo usermod -aG nordvpn $USER && sudo reboot |
| 2 | Browser auth fails on headless server | nordvpn login --token <token> — generate token from Nord account portal |
| 3 | VPN kills SSH session on connect | Expected behavior — reconnect after VPN comes up |
| 4 | VPN blocks LAN SSH after reconnect | nordvpn set lan-discovery disable then nordvpn whitelist add subnet 192.168.0.0/24 — order is mandatory |
| 5 | SSH port forwarding fails after killswitch | Use localhost not 127.0.0.1: ssh -L 18789:localhost:18789 |
| 6 | Two groups named 9OS in Graph API | Check resourceProvisioningOptions — use the group where 'Team' is in the array |
| 7 | SharePoint returns 401 with delegated permissions | Sites.Selected grants nothing until mapped — POST to /sites/{site-id}/permissions (or Grant-PnPAzureADAppSitePermission) to grant the app per-site read/write access |
| 8 | Adding msgraph key breaks openclaw.json validation | Keep Graph config in ~/.openclaw/msgraph.json with chmod 600 — never add it to openclaw.json |
| 9 | Nine responds with OpenClaw developer boilerplate | Workspace is pointing at the OpenClaw source repo — set it to /home/paragon9/9os/workspace |
| 10 | Teams messages never reach OpenClaw | Inbound webhook requires Cloudflare Tunnel — NordVPN only handles outbound |
| 11 | Cloudflare service install fails | Always use explicit config path:sudo cloudflared --config ~/.cloudflared/config.yml service install |
Services Running on Boot
After completing this series, these services auto-start on reboot:
sudo systemctl status openclaw # OpenClaw agent runtime (LOG 010.1)
systemctl --user status openclaw-gateway.service # OpenClaw gateway (LOG 010.1)
sudo systemctl status cloudflared-bot # Cloudflare inbound tunnel (LOG 010.2)
NordVPN auto-connect handles the VPN — no separate service entry needed.
Maintenance Notes
Client secret rotation (every 24 months):
- Generate a new secret in Entra → App registrations → 9OS-OpenClaw → Certificates & secrets
- Update
~/.openclaw/msgraph.jsonwith the new secret value sudo systemctl restart openclaw && node openclaw.mjs gateway restart- Delete the old secret in Entra
If your dedicated IP changes:
Update the Named Location in Entra Conditional Access to match the new IP before reconnecting the VPN — Conditional Access checks the IP at authentication time, not on a schedule.
Adding per-principal scoped agents:
Agent identities follow the pattern 9-[name]@paragon9.com. Each gets a scoped SharePoint Command Deck and its own agent identity file. That is a separate series entry.
[SYSTEM NOTES]
- Graph API: App registration
9OS-OpenClaw— both delegated and application permissions, admin consent granted - Credential file:
~/.openclaw/msgraph.jsonwithchmod 600— separate fromopenclaw.jsonby design - SharePoint 401 fix:
Sites.Selectedmust be mapped per-site viaPOST /sites/{site-id}/permissions(orGrant-PnPAzureADAppSitePermission) — the scope grants nothing until the app is explicitly granted on each site - SOUL.md location:
~/.openclaw/agents/main/agent/SOUL.md+ copy at/home/paragon9/9os/workspace/SOUL.md - Workspace isolation:
/home/paragon9/9os/workspace— separate from OpenClaw source to avoid conflictingAGENTS.md - Inbound delivery: Cloudflare Tunnel →
localhost:3978(Teams webhook) installed as systemd service - Secret rotation: Every 24 months — set a calendar reminder now
- Status: Nine is online. 9OS infrastructure live. Phase 1 complete.
Stay Resilient.
— Greg Scott Kirk (Tek)
Part 4 of the Nine / 9OS series.
← Previous: LOG 010.4 — Building the SharePoint 9OS Command Deck and Teams Channels
→ Next: LOG 010.6: Connecting Nine to Azure DevOps — and Getting Credential Custody Right
This is the architecture Paragon9 installs for mid-market firms — modernized, documented, and built to run without its operator. If you want it running in your business, the conversation is free.