The power of GitHub Copilot customization becomes clear when you see concrete examples of how agents, skills, and instructions transform everyday development workflows. This article presents real-world scenarios showing the dramatic difference between default Copilot behavior and customized experiences that align with your team’s standards, tools, and practices.

Note: The following examples illustrate typical before-and-after scenarios. The actual before and after code may vary depending on the model used and any other context present at generation time.

Example 1: API Client Code Generation

Before: Generic API Code

Without customization, GitHub Copilot generates generic HTTP request code that may not follow your team’s patterns:

// user-api.ts
async function getUser(userId: string) {
  // Default Copilot suggestion
  const response = await fetch(`https://api.example.com/users/${userId}`);
  const data = await response.json();
  return data;
}

Problems:

  • No error handling or retry logic
  • Doesn’t use existing HTTP client utilities
  • Missing type safety
  • No logging or telemetry
  • Hardcoded base URL

After: With Custom Instructions

Create .github/instructions/typescript-api.md:

---
description: 'API client patterns for our application'
applyTo: '**/*-api.ts, **/services/**/*.ts'
---

When generating API client code:
- Always use the HttpClient class from `@/lib/http-client`
- Include proper TypeScript types for request/response
- Add structured error handling with our ApiError class
- Include retry logic for transient failures
- Use environment-based configuration for base URLs
- Add telemetry using our logger utility
- Never hardcode API endpoints

Example pattern:
```typescript
import { httpClient } from '@/lib/http-client';
import { ApiError } from '@/lib/errors';
import { logger } from '@/lib/logger';

async function fetchResource<T>(endpoint: string): Promise<T> {
  try {
    const response = await httpClient.get<T>(endpoint);
    logger.debug('API call successful', { endpoint });
    return response.data;
  } catch (error) {
    logger.error('API call failed', { endpoint, error });
    throw new ApiError('Failed to fetch resource', error);
  }
}
```

Now Copilot generates code aligned with your team’s patterns:

// user-api.ts
import { httpClient } from '@/lib/http-client';
import { ApiError } from '@/lib/errors';
import { logger } from '@/lib/logger';

interface User {
  id: string;
  name: string;
  email: string;
}

async function getUser(userId: string): Promise<User> {
  try {
    const response = await httpClient.get<User>(`/users/${userId}`);
    logger.debug('User fetched successfully', { userId });
    return response.data;
  } catch (error) {
    logger.error('Failed to fetch user', { userId, error });
    throw new ApiError('Unable to retrieve user data', error);
  }
}

Benefits:

  • Automatically uses your team’s HTTP client
  • Includes proper error handling and logging
  • Type-safe with your interfaces
  • Follows team conventions consistently
  • No manual corrections needed

Example 2: Test Generation

Before: Basic Test Structure

Default Copilot test suggestions are often generic and miss project-specific patterns:

// user-service.test.ts
import { UserService } from './user-service';

describe('UserService', () => {
  it('should get user by id', async () => {
    const service = new UserService();
    const user = await service.getUserById('123');
    expect(user).toBeDefined();
  });
});

Problems:

  • No test fixtures or factories
  • Missing setup/teardown
  • Doesn’t use testing utilities
  • No mocking strategy
  • Incomplete assertions

After: With Custom Testing Skill

Create a skill folder .github/skills/generate-tests/ with a SKILL.md:

---
name: generate-tests
description: 'Generate comprehensive test suites using our testing patterns, including fixtures, setup/teardown, and thorough assertions'
---

# generate-tests

Generate a comprehensive test suite for the selected code following these patterns:

**Setup Requirements**:
- Use our test factory functions from `@/test/factories`
- Set up database transactions with `setupTestDb()` and `cleanupTestDb()`
- Mock external services using our mock utilities from `@/test/mocks`

**Test Structure**:
- Group related tests in `describe` blocks
- Use descriptive test names that explain behavior: "should [action] when [condition]"
- Include setup/teardown in `beforeEach`/`afterEach`
- Test happy path, edge cases, and error scenarios

**Assertions**:
- Use specific assertions, not just `.toBeDefined()`
- Verify all relevant properties of returned objects
- Check error messages and types for failure cases
- Assert side effects (database changes, API calls, events)

See [references/test-patterns.md](references/test-patterns.md) for standard patterns and [templates/test-template.ts](templates/test-template.ts) for a starter structure.

**Example Pattern**:
```typescript
import { setupTestDb, cleanupTestDb } from '@/test/setup';
import { createUser, createPost } from '@/test/factories';
import { mockApiClient } from '@/test/mocks';

describe('UserService', () => {
  beforeEach(async () => {
    await setupTestDb();
  });

  afterEach(async () => {
    await cleanupTestDb();
  });

  describe('getUserById', () => {
    it('should return user when found', async () => {
      const user = await createUser({ name: 'Alice' });
      const service = new UserService();
      
      const result = await service.getUserById(user.id);
      
      expect(result).toEqual({
        id: user.id,
        name: 'Alice',
        email: expect.any(String)
      });
    });

    it('should throw NotFoundError when user does not exist', async () => {
      const service = new UserService();
      
      await expect(service.getUserById('nonexistent'))
        .rejects.toThrow(NotFoundError);
    });
  });
});
```

Generate tests following this pattern for the selected code.

Now when you select code and use /generate-tests, or when an agent detects a testing need, Copilot produces comprehensive test suites:

// user-service.test.ts
import { setupTestDb, cleanupTestDb } from '@/test/setup';
import { createUser, createPost } from '@/test/factories';
import { mockApiClient } from '@/test/mocks';
import { UserService } from './user-service';
import { NotFoundError } from '@/lib/errors';

describe('UserService', () => {
  let service: UserService;

  beforeEach(async () => {
    await setupTestDb();
    service = new UserService();
  });

  afterEach(async () => {
    await cleanupTestDb();
  });

  describe('getUserById', () => {
    it('should return user with all properties when found', async () => {
      const user = await createUser({ 
        name: 'Alice Johnson',
        email: 'alice@example.com'
      });
      
      const result = await service.getUserById(user.id);
      
      expect(result).toEqual({
        id: user.id,
        name: 'Alice Johnson',
        email: 'alice@example.com',
        createdAt: expect.any(Date)
      });
    });

    it('should throw NotFoundError when user does not exist', async () => {
      await expect(service.getUserById('nonexistent-id'))
        .rejects.toThrow(NotFoundError);
    });

    it('should throw NotFoundError with correct message', async () => {
      await expect(service.getUserById('invalid'))
        .rejects.toThrow('User not found');
    });
  });

  describe('getUserPosts', () => {
    it('should return all posts for a user', async () => {
      const user = await createUser();
      const post1 = await createPost({ userId: user.id, title: 'First Post' });
      const post2 = await createPost({ userId: user.id, title: 'Second Post' });
      
      const result = await service.getUserPosts(user.id);
      
      expect(result).toHaveLength(2);
      expect(result[0]).toMatchObject({ title: 'First Post' });
      expect(result[1]).toMatchObject({ title: 'Second Post' });
    });

    it('should return empty array when user has no posts', async () => {
      const user = await createUser();
      
      const result = await service.getUserPosts(user.id);
      
      expect(result).toEqual([]);
    });
  });
});

Benefits:

  • Richer test coverage including edge cases
  • Uses team’s testing utilities and patterns
  • Proper setup/teardown
  • Comprehensive assertions
  • Ready to run without modifications

Example 3: Infrastructure as Code with Terraform

Before: Basic Terraform Resources

Default suggestions lack organization, security best practices, and team conventions:

# main.tf
resource "azurerm_resource_group" "rg" {
  name     = "my-rg"
  location = "eastus"
}

resource "azurerm_storage_account" "data" {
  name                     = "mystorage"
  resource_group_name      = azurerm_resource_group.rg.name
  location                 = azurerm_resource_group.rg.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
  # Defaults used; public network access allowed
}

Problems:

  • No consistent naming aligned to Azure conventions
  • Missing tags
  • Public network access not blocked; no private endpoint or network rules
  • No diagnostic settings for auditing
  • Hardcoded values without variables

After: With Terraform Custom Agent

Use a custom agent from the repository agents/terraform-azure-implement.agent.md (click through to see full definition).

Now Copilot has access to a custom agent that enforces your organization’s Terraform best practices for Azure.

# storage.tf
# Azure Storage Account for application data with secure networking and diagnostics

locals {
  storage_account_name = lower(replace("${var.environment}-${var.project_name}-st-app-data", "-", ""))

  common_tags = {
    Environment = var.environment
    Project     = var.project_name
    ManagedBy   = "Terraform"
    Owner       = var.owner_team
    CostCenter  = var.cost_center
  }
}

resource "azurerm_resource_group" "rg" {
  name     = "${var.environment}-${var.project_name}-rg"
  location = var.location

  tags = merge(local.common_tags, { Name = "${var.environment}-${var.project_name}-rg" })
}

resource "azurerm_storage_account" "app_data" {
  name                     = local.storage_account_name
  resource_group_name      = azurerm_resource_group.rg.name
  location                 = azurerm_resource_group.rg.location
  account_tier             = "Standard"
  account_replication_type = "ZRS"
  min_tls_version          = "TLS1_2"
  public_network_access_enabled = false

  tags = merge(local.common_tags, {
    Name        = local.storage_account_name
    Description = "Application data storage account"
  })
}

resource "azurerm_log_analytics_workspace" "law" {
  name                = "${var.environment}-${var.project_name}-law"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  sku                 = "PerGB2018"
  retention_in_days   = 30

  tags = local.common_tags
}

resource "azurerm_monitor_diagnostic_setting" "sa_diagnostics" {
  name                           = "${local.storage_account_name}-diag"
  target_resource_id             = azurerm_storage_account.app_data.id
  log_analytics_workspace_id     = azurerm_log_analytics_workspace.law.id

  enabled_log {
    category = "StorageRead"
  }

  enabled_log {
    category = "StorageWrite"
  }

  enabled_log {
    category = "StorageDelete"
  }

  metric {
    category = "AllMetrics"
    enabled  = true
  }
}

output "storage_account_name" {
  description = "Name of the application data Storage Account"
  value       = azurerm_storage_account.app_data.name
}

output "storage_account_id" {
  description = "ID of the application data Storage Account"
  value       = azurerm_storage_account.app_data.id
}

Benefits:

  • Secure-by-default configuration (encryption at rest, TLS 1.2+)
  • Follows Azure naming and tagging conventions
  • Public access blocked with diagnostics enabled
  • Clean separation of resources and outputs
  • Ready to extend with private endpoints and CMK if required

Example 4: Code Review Comments

Before: Manual Review Process

Developers manually review pull requests and write comments, which can be time-consuming and inconsistent:

Manual Process:

  1. Read through code changes
  2. Think about potential issues
  3. Write detailed feedback
  4. Format code suggestions
  5. Ensure constructive tone

Time investment: 20-30 minutes per PR

After: With Code Review Skill

Create a skill folder skills/review-pr/ with a SKILL.md:

---
name: review-pr
description: 'Generate comprehensive code review with actionable feedback, covering correctness, security, performance, and maintainability'
---

# review-pr

Analyze the current git diff and provide a structured code review with:

**Structure**:
1. **Summary**: Brief overview of changes
2. **Positive Feedback**: What's done well
3. **Issues Found**: Problems categorized by severity (Critical/Major/Minor)
4. **Suggestions**: Specific improvements with code examples
5. **Questions**: Clarifications needed

**Review Focus**:
- Code correctness and logic errors
- Security vulnerabilities
- Performance implications
- Test coverage
- Code style and maintainability
- Error handling
- Documentation needs

**Tone**:
- Be constructive and helpful
- Explain the "why" behind suggestions
- Offer specific fixes, not just critique
- Acknowledge good patterns

**Format**:
Use markdown with code blocks for suggestions. For each issue, provide:
- Clear description of the problem
- Why it matters
- Suggested fix with code example

See [references/review-checklist.md](references/review-checklist.md) for the standard review checklist.

Example format:
## 🔴 Critical Issues

### Missing Input Validation
**Problem**: The `processUser` function doesn't validate the email format.
**Impact**: Could lead to invalid data in the database.
**Fix**:
```typescript
function processUser(user: User) {
  if (!isValidEmail(user.email)) {
    throw new ValidationError('Invalid email format');
  }
  // ... rest of function
}
```

Using this skill on a PR (via /review-pr or agent invocation) generates structured, actionable feedback:

## Summary
This PR adds user authentication with JWT tokens. The implementation includes login, token generation, and middleware for protected routes.

## 🎉 Positive Feedback
- Good separation of concerns with separate auth service and middleware
- Comprehensive error handling in the login flow
- Clear function naming that makes the code self-documenting

## 🔴 Critical Issues

### 1. Hardcoded Secret Key
**Problem**: The JWT secret is hardcoded in `auth.service.ts`:
```typescript
const secret = 'my-secret-key'; // ❌ Hardcoded
```
**Impact**: Security vulnerability - secrets should never be in source code.
**Fix**:
```typescript
const secret = process.env.JWT_SECRET;
if (!secret) {
  throw new Error('JWT_SECRET environment variable is required');
}
```

### 2. Missing Password Hashing Salt Rounds
**Problem**: Password hashing uses default bcrypt rounds which may be too low.
**Impact**: Weak password protection if defaults are insufficient.
**Fix**:
```typescript
const SALT_ROUNDS = 12; // Explicit configuration
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
```

## ⚠️ Major Issues

### 3. No Token Expiration Validation
**Problem**: The auth middleware doesn't check token expiration explicitly.
**Impact**: Expired tokens might be accepted if JWT library doesn't verify.
**Fix**: Add explicit expiration check in the middleware.

## 💡 Suggestions

### 4. Consider Adding Refresh Tokens
The current implementation uses only access tokens. Consider implementing refresh tokens for better security and user experience:
- Short-lived access tokens (15 minutes)
- Longer-lived refresh tokens (7 days)
- Token rotation on refresh

### 5. Add Rate Limiting
The login endpoint should have rate limiting to prevent brute force attacks:
```typescript
import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts
  message: 'Too many login attempts, please try again later'
});

app.post('/login', loginLimiter, authController.login);
```

## ❓ Questions
1. What's the intended token expiration time for production?
2. Should we implement token revocation for logout functionality?
3. Are there plans for multi-factor authentication in the future?

Benefits:

  • Consistent review quality
  • Faster review process (5-10 minutes)
  • Structured, actionable feedback
  • Includes code examples for fixes
  • Identifies security issues systematically
  • Maintains constructive tone

Key Takeaways

These examples demonstrate how customization transforms GitHub Copilot from a general-purpose assistant into a team-specific expert:

  1. Instructions embed your team’s patterns into every suggestion automatically
  2. Skills standardize workflows with bundled resources and enable agent discovery
  3. Agents bring specialized expertise for complex domains
  4. Combination of all three creates a comprehensive development assistant

The investment in creating customizations pays dividends through:

  • Faster development with fewer manual corrections
  • Consistent code quality across the team
  • Automatic adherence to best practices
  • Reduced onboarding time for new team members
  • Better security and maintainability