Learning Objectives
By the end of this lesson, you will be able to:
- Distinguish when to build private vs public workflows
- Configure visibility and access controls in workflow metadata
- Define
accessGrantswith email domains and specific user permissions - Implement BYOO (Bring Your Own OAuth) patterns for client credentials
- Use persistent storage for organization-specific data isolation
Not every workflow belongs in the marketplace. Private workflows serve specific organizations with custom requirements while leveraging the full WORKWAY platform.
Real Example: Gmail to Notion Private
Here's how the production gmail-to-notion-private workflow configures private visibility and access grants:
// From packages/workflows/src/gmail-to-notion-private/index.ts
/**
* Workflow metadata - Private workflow for @halfdozen.co
*/
export const metadata = {
id: 'gmail-to-notion-private',
category: 'productivity',
featured: false,
// Private workflow - requires WORKWAY login
visibility: 'private' as const,
accessGrants: [{ type: 'email_domain' as const, value: 'halfdozen.co' }],
// Honest flags (matches meeting-intelligence-private pattern)
experimental: true,
requiresCustomInfrastructure: true,
canonicalAlternative: 'gmail-to-notion', // Future public version
// Why this exists
workaroundReason: 'Gmail OAuth scopes require Google app verification for public apps',
infrastructureRequired: ['BYOO Google OAuth app', 'Arc for Gmail worker'],
// Upgrade path (when Google verification completes)
upgradeTarget: 'gmail-to-notion',
upgradeCondition: 'When WORKWAY Gmail OAuth app is verified',
// Analytics URL - unified at workway.co/workflows
analyticsUrl: 'https://workway.co/workflows/private/gmail-to-notion-private/analytics',
// Setup URL - initial BYOO connection setup
setupUrl: 'https://arc.halfdozen.co/setup',
stats: { rating: 0, users: 0, reviews: 0 },
};
Key patterns to notice:
visibility: 'private' as const- TypeScript literal type for compile-time safetyaccessGrants- Array of access rules (email_domain, email, organization)experimentalandrequiresCustomInfrastructure- Honest flags about workflow requirementscanonicalAlternativeandupgradeTarget- Points users to the standard path when available
Step-by-Step: Create Your First Private Workflow
Step 1: Initialize the Project
Create a new workflow with private visibility:
mkdir client-meeting-sync
cd client-meeting-sync
pnpm init
pnpm add @workwayco/sdk
Step 2: Define the Workflow Structure
Create src/index.ts:
import { defineWorkflow, webhook } from '@workwayco/sdk';
export default defineWorkflow({
name: 'Client Meeting Sync',
description: 'Syncs meeting data to internal CRM',
version: '1.0.0',
integrations: [
{ service: 'zoom', scopes: ['meeting:read'] },
],
inputs: {
crmEndpoint: { type: 'text', label: 'CRM API Endpoint', required: true },
},
trigger: webhook({
service: 'zoom',
event: 'meeting.ended',
}),
async execute({ trigger, inputs, integrations }) {
const { zoom } = integrations;
const meeting = await zoom.getMeeting(trigger.data.object.id);
// Sync to internal CRM
await fetch(inputs.crmEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ meeting: meeting.data }),
});
return { success: true };
},
});
Step 3: Add Private Metadata
Export the private workflow configuration:
// Add at the bottom of src/index.ts
export const metadata = {
id: 'client-meeting-sync',
visibility: 'private' as const,
accessGrants: [
{ type: 'email_domain' as const, value: 'clientcorp.com' },
],
};
Step 4: Test Locally
workway dev
# In another terminal
curl localhost:8787/execute \
-H "Content-Type: application/json" \
-d '{"object": {"id": "test-123", "topic": "Test Meeting"}}'
Step 5: Deploy as Private
workway deploy
# Output:
# ✓ Deployed: client-meeting-sync
# ✓ Visibility: private
# ✓ Access: clientcorp.com email domain
# ✓ URL: workway.co/workflows/private/client-meeting-sync
Step 6: Share with Client
Send the install link to authorized users:
https://workway.co/workflows/private/client-meeting-sync
They'll authenticate, verify access, connect integrations, and configure.
Public vs. Private
| Aspect | Public Workflow | Private Workflow |
|---|---|---|
| Visibility | Marketplace listing | Organization only |
| Access | Anyone can install | Authorized users only |
| Discovery | Searchable | Hidden from search |
| Pricing | Per-execution fees | Custom arrangements |
| Customization | Config options only | Full customization |
When to Build Private
Private workflows make sense when:
- Proprietary process: Your competitive advantage shouldn't be public
- Client-specific: Built for a specific organization's needs
- Sensitive data: Handles data that shouldn't touch shared infrastructure
- Custom integrations: Uses internal APIs or systems
- Compliance: Requires audit trails or specific controls
Defining Private Workflows
import { defineWorkflow, webhook } from '@workwayco/sdk';
export default defineWorkflow({
name: 'Client CRM Sync',
description: 'Syncs meeting data to internal CRM',
version: '1.0.0',
integrations: [
{ service: 'zoom', scopes: ['meeting:read'] },
],
inputs: {
crmEndpoint: { type: 'text', label: 'CRM API Endpoint', required: true },
},
trigger: webhook({
service: 'zoom',
event: 'meeting.ended',
}),
async execute({ trigger, inputs, integrations }) {
// ... workflow logic
},
});
// Private workflow metadata is exported separately
export const metadata = {
id: 'client-crm-sync',
visibility: 'private' as const,
accessGrants: [
{ type: 'email_domain' as const, value: 'acmecorp.com' },
{ type: 'email' as const, value: '[email protected]' },
{ type: 'organization' as const, value: 'org_123' },
],
};
Access Control
Access grants determine who can install and use your private workflow. All grants use TypeScript const assertions for type safety.
Email Domain
Allow anyone from a company domain:
// From gmail-to-notion-private - restricts to @halfdozen.co team
accessGrants: [
{ type: 'email_domain' as const, value: 'halfdozen.co' },
]
Anyone with @halfdozen.co email can install and use the workflow.
Specific Emails
Grant access to specific individuals (useful for external collaborators):
accessGrants: [
{ type: 'email' as const, value: '[email protected]' },
{ type: 'email' as const, value: '[email protected]' },
]
Organization ID
Link to WORKWAY organization:
accessGrants: [
{ type: 'organization' as const, value: 'org_abc123' },
]
All members of the organization can access.
Combined Access (Real Pattern)
From the production gmail-to-notion-private workflow header comments:
// Real-world example: Company + external auditor
export const metadata = {
id: 'acme-meeting-processor',
visibility: 'private' as const,
accessGrants: [
{ type: 'email_domain' as const, value: 'acmecorp.com' }, // Company employees
{ type: 'email' as const, value: '[email protected]' }, // External auditor
],
// ... other metadata
};
Access Grant Types Reference
| Type | Value | Who Gets Access |
|---|---|---|
email_domain |
'company.com' |
Anyone with @company.com email |
email |
'[email protected]' |
That specific email only |
organization |
'org_abc123' |
All WORKWAY org members |
BYOO: Bring Your Own OAuth
For clients who need their own API credentials:
import { defineWorkflow, webhook } from '@workwayco/sdk';
export default defineWorkflow({
name: 'Enterprise Zoom Sync',
version: '1.0.0',
integrations: [
{ service: 'zoom', scopes: ['meeting:read', 'recording:read'] },
{ service: 'notion', scopes: ['read_pages', 'write_pages'] },
],
inputs: {
zoomClientId: {
type: 'text',
label: 'Zoom Client ID',
required: true,
},
zoomClientSecret: {
type: 'text',
label: 'Zoom Client Secret',
required: true,
},
notionApiKey: {
type: 'text',
label: 'Notion API Key',
required: true,
},
notionDatabase: {
type: 'text',
label: 'Notion Database ID',
required: true,
},
},
trigger: webhook({ service: 'zoom', event: 'recording.completed' }),
async execute({ trigger, inputs, integrations }) {
// Workflow uses client's credentials via BYOO
// ...
},
});
export const metadata = {
id: 'enterprise-zoom-sync',
visibility: 'private' as const,
byoo: {
enabled: true,
providers: ['zoom', 'notion'],
instructions: `
This workflow requires your organization's OAuth credentials.
1. Create a Zoom Server-to-Server app at marketplace.zoom.us
2. Create a Notion integration at notion.so/my-integrations
3. Enter your credentials during setup
`,
},
};
When to Use BYOO
- Enterprise security: Client requires their own credentials
- API quotas: Client has higher limits on their own account
- Audit requirements: All API calls must originate from client's credentials
- Data sovereignty: Data must only flow through client's accounts
Private Analytics
Track private workflow usage:
metadata: {
id: 'client-workflow',
visibility: 'private',
analytics: {
enabled: true,
dashboardUrl: 'https://workway.co/workflows/private/client-workflow/analytics',
retention: '90d',
exportEnabled: true,
},
},
Analytics available:
- Execution count
- Success/failure rates
- Average execution time
- Error breakdown
- Usage by user
Private Workflow URLs
| Purpose | URL Pattern |
|---|---|
| Install | workway.co/workflows/private/{workflow-id} |
| Configure | workway.co/workflows/private/{workflow-id}/configure |
| Analytics | workway.co/workflows/private/{workflow-id}/analytics |
| Logs | workway.co/workflows/private/{workflow-id}/logs |
Users access via the unified workflows page—no separate dashboard.
Deployment Pattern
1. Develop Locally
mkdir client-workflow
cd client-workflow
workway init --private
2. Configure Access
Edit workway.config.ts:
export default {
visibility: 'private',
accessGrants: [
{ type: 'email_domain', value: 'clientcorp.com' },
],
};
3. Deploy
workway deploy
4. Share with Client
Send them the install link:
https://workway.co/workflows/private/client-workflow
They'll authenticate, verify access, and configure.
Client Onboarding
Setup Flow
Client clicks install link
↓
Authenticates with WORKWAY
↓
System verifies access grant
↓
Client connects integrations (or enters BYOO credentials)
↓
Client configures workflow options
↓
Workflow activates
Custom Setup Pages
For complex configurations:
metadata: {
setupUrl: 'https://client-setup.workway.co/onboard',
},
This redirects to your custom setup experience before returning to WORKWAY.
Versioning Private Workflows
Semantic Versions
metadata: {
id: 'client-workflow',
version: '2.1.0',
changelog: `
2.1.0 - Added Slack integration
2.0.0 - Breaking: New config schema
1.1.0 - Performance improvements
1.0.0 - Initial release
`,
},
Gradual Rollouts
metadata: {
version: '2.0.0',
rollout: {
strategy: 'gradual',
percentage: 10, // 10% of installations get new version
},
},
Migration Support
When config schema changes:
metadata: {
version: '2.0.0',
migration: {
from: '1.x',
script: async (oldConfig) => {
return {
...oldConfig,
// Map old fields to new
newField: oldConfig.deprecatedField || 'default',
};
},
},
},
Security Considerations
Data Isolation
Private workflows run in isolated environments:
async execute({ storage }) {
// Storage is isolated per workflow per organization
await storage.put('key', 'value');
// This key is only accessible by this workflow
// for this organization
}
Secrets Management
inputs: {
apiKey: {
type: 'text',
label: 'API Key',
required: true,
// Note: Sensitive inputs are encrypted at rest
// Never logged, never exposed in responses
},
},
Audit Logging
Enable for compliance:
metadata: {
audit: {
enabled: true,
events: ['execute', 'config_change', 'access_grant'],
retention: '365d',
},
},
Complete Example
This example shows the complete private workflow pattern, including all metadata fields used in production workflows:
import { defineWorkflow, cron } from '@workwayco/sdk';
// Organization-specific constants (hardcoded for internal workflows)
const INTERNAL_DATABASE_ID = '27a019187ac580b797fec563c98afbbc';
const INTERNAL_DOMAINS = ['acmecorp.com'];
export default defineWorkflow({
name: 'ACME Meeting Processor',
description: 'Internal workflow for @acmecorp.com. Meetings sync to central database.',
version: '1.2.0',
// Pathway metadata for discovery (optional but recommended)
pathway: {
outcomeFrame: 'after_meetings',
outcomeStatement: {
suggestion: 'Want meetings to document themselves?',
explanation: 'After every meeting, a Notion page appears with notes and action items.',
outcome: 'Meetings that document themselves',
},
zuhandenheit: {
timeToValue: 5, // Minutes - be honest about setup time
worksOutOfBox: false, // Requires custom setup
gracefulDegradation: true,
automaticTrigger: false,
},
},
pricing: {
model: 'usage',
pricePerExecution: 0.05,
freeExecutions: 50,
description: 'Per meeting processed',
},
integrations: [
{ service: 'zoom', scopes: ['meeting:read'] },
{ service: 'notion', scopes: ['read_pages', 'write_pages', 'read_databases'] },
],
inputs: {
connectionId: {
type: 'string',
label: 'Connection ID',
required: true,
description: 'Your unique identifier (set during setup)',
},
},
// Cron trigger - runs every 5 minutes
trigger: cron({
schedule: '*/5 * * * *',
timezone: 'UTC',
}),
async execute({ inputs, integrations, env }) {
const startTime = Date.now();
// Get meetings and process them
const meetings = await integrations.zoom.listMeetings({ type: 'past' });
for (const meeting of meetings.data || []) {
// Create Notion page (using hardcoded internal database)
await integrations.notion.pages.create({
parent: { database_id: INTERNAL_DATABASE_ID },
properties: {
Name: { title: [{ text: { content: meeting.topic } }] },
Date: { date: { start: meeting.start_time } },
Type: { select: { name: 'Meeting' } },
},
});
}
console.log('Meetings processed', {
count: meetings.data?.length || 0,
executionTimeMs: Date.now() - startTime,
});
return {
success: true,
processed: meetings.data?.length || 0,
analyticsUrl: 'https://workway.co/workflows/private/acme-meeting-processor/analytics',
};
},
onError: async ({ error, inputs }) => {
console.error(`Workflow failed for ${inputs.connectionId}:`, error);
},
});
// Private workflow metadata - CRITICAL: This is what makes it private
export const metadata = {
id: 'acme-meeting-processor',
category: 'productivity',
featured: false,
// REQUIRED for private workflows
visibility: 'private' as const,
accessGrants: [
{ type: 'email_domain' as const, value: 'acmecorp.com' },
{ type: 'email' as const, value: '[email protected]' },
],
// Honest flags about requirements
experimental: true,
requiresCustomInfrastructure: true,
canonicalAlternative: 'meeting-intelligence', // Public version
// Why this private version exists
workaroundReason: 'Organization requires internal database and custom auth',
infrastructureRequired: ['BYOO OAuth app', 'Internal Notion database'],
// Upgrade path when no longer needed
upgradeTarget: 'meeting-intelligence',
upgradeCondition: 'When org approves shared infrastructure',
// URLs for the unified workflows page
analyticsUrl: 'https://workway.co/workflows/private/acme-meeting-processor/analytics',
setupUrl: 'https://acme-setup.workway.co/setup',
stats: { rating: 0, users: 0, reviews: 0 },
};
Key Metadata Fields Explained
| Field | Purpose | Required |
|---|---|---|
visibility: 'private' |
Hides from marketplace, requires auth | Yes |
accessGrants |
Who can install | Yes |
experimental |
Honest flag about stability | Recommended |
requiresCustomInfrastructure |
Needs non-standard setup | Recommended |
canonicalAlternative |
Points to public version | Recommended |
workaroundReason |
Documents why private exists | Recommended |
upgradeTarget |
Public version to migrate to | Recommended |
analyticsUrl |
Dashboard URL | Optional |
setupUrl |
Custom setup page | Optional |
Praxis
Design a private workflow for your organization or a client:
Praxis: Ask Claude Code: "Help me create a private workflow with access controls for [organization/client]"
Create the complete private workflow metadata with all recommended fields:
// Your private workflow metadata
export const metadata = {
id: 'my-private-workflow',
category: 'productivity',
featured: false,
// REQUIRED: Private visibility
visibility: 'private' as const,
// REQUIRED: Who can access
accessGrants: [
{ type: 'email_domain' as const, value: 'yourcompany.com' },
{ type: 'email' as const, value: '[email protected]' },
],
// RECOMMENDED: Honest flags
experimental: true,
requiresCustomInfrastructure: true,
canonicalAlternative: 'public-workflow-id',
// RECOMMENDED: Document why this exists
workaroundReason: 'Describe why private version is needed',
infrastructureRequired: ['List', 'of', 'requirements'],
// RECOMMENDED: Upgrade path
upgradeTarget: 'public-workflow-id',
upgradeCondition: 'When X condition is met',
// OPTIONAL: URLs
analyticsUrl: 'https://workway.co/workflows/private/my-private-workflow/analytics',
setupUrl: 'https://your-worker.workway.co/setup',
stats: { rating: 0, users: 0, reviews: 0 },
};
Walk through the deployment and onboarding:
- Deploy:
workway deploy - Share install link:
https://workway.co/workflows/private/my-private-workflow - Verify access: Test with an authorized email
- Configure: Connect integrations and set options
- Monitor: Check analytics dashboard
Validation Checklist
Your praxis will be validated for these patterns:
-
visibility: 'private' as const- TypeScript literal type -
accessGrantsarray with at least one grant -
type: 'email_domain' as constortype: 'email' as constpatterns - Honest flags (
experimental,requiresCustomInfrastructure) - Upgrade path documented (
canonicalAlternative,upgradeTarget)
Reflection
- What workflows in your organization should be private?
- How does BYOO change the trust model with clients?
- What audit requirements do your clients have?
- When would you use
email_domainvsemailvsorganizationgrants?